first commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
@@ -0,0 +1,288 @@
|
||||
# ComfyUI-UniversalRemove
|
||||
|
||||
一个自包含的 ComfyUI remove 核心扩展。
|
||||
|
||||
## 设计目标
|
||||
|
||||
- 不硬编码任何本地路径
|
||||
- 不依赖其他自定义节点
|
||||
- 只使用 ComfyUI 常见核心类型:`IMAGE` / `MASK` / `STRING` / `INT` / `FLOAT`
|
||||
- 面向 **图像批 / 视频帧批** 工作,适合作为通用 remove 流程的中间层
|
||||
|
||||
## 当前提供的节点
|
||||
|
||||
### 0. `UR Demo Image`
|
||||
|
||||
生成一个无需外部图片文件的测试图像,主要用于示例工作流和安装验证。
|
||||
|
||||
### 1. `UR Remove Prompt Builder`
|
||||
|
||||
输入目标描述,输出三路提示词:
|
||||
|
||||
- `segmentation_prompt`
|
||||
- `remove_positive_prompt`
|
||||
- `remove_negative_prompt`
|
||||
|
||||
用途:
|
||||
|
||||
- `segmentation_prompt` 给任意文本选目标模块
|
||||
- `remove_positive_prompt` 给关键帧修复模型
|
||||
- `remove_negative_prompt` 给关键帧修复模型的负向提示
|
||||
|
||||
### 2. `UR Region Editor`
|
||||
|
||||
交互式前端 region 编辑器,输出标准 `regions_json`。
|
||||
|
||||
支持:
|
||||
|
||||
- 加载本地图片作为编辑背景
|
||||
- 加载本地视频作为编辑背景并拖动时间轴
|
||||
- 使用当前视频时间写入关键帧编号
|
||||
- 绘制矩形区域
|
||||
- 绘制圆形区域
|
||||
- 绘制多边形区域
|
||||
- 删除选中区域
|
||||
- 清空全部区域
|
||||
|
||||
注意:背景图片 / 视频只用于前端交互预览,不会写进 workflow。真正可移植、可复用的是输出的 `regions_json`。
|
||||
|
||||
### 3. `UR Boxes To Mask Batch`
|
||||
|
||||
把 JSON 形式的矩形框转换成 `MASK` 批次。
|
||||
|
||||
支持:
|
||||
|
||||
- `xyxy`
|
||||
- `xywh`
|
||||
- 归一化坐标
|
||||
- 三种广播模式:
|
||||
- `repeat_to_batch`
|
||||
- `single_frame_only`
|
||||
- `hold_after_frame`
|
||||
|
||||
用途:
|
||||
|
||||
- 手工框选移除区域
|
||||
- 从外部工具导入 bbox
|
||||
|
||||
### 4. `UR Regions To Mask Batch`
|
||||
|
||||
把关键帧 region JSON 转换成整段图像批 / 视频帧批的 `MASK`。
|
||||
|
||||
支持:
|
||||
|
||||
- `rect`
|
||||
- `circle`
|
||||
- `polygon`
|
||||
- 单关键帧
|
||||
- 多关键帧
|
||||
- 四种广播模式:
|
||||
- `repeat_to_batch`
|
||||
- `single_frame_only`
|
||||
- `hold_after_frame`
|
||||
- `hold_until_next_keyframe`
|
||||
|
||||
用途:
|
||||
|
||||
- 用 JSON 描述“关键帧上的圈选区域”
|
||||
- 为视频帧批生成对应 mask
|
||||
- 后续接 `UR Mask Processor` / `UR Keyframe Prep` / `UR Soft Composite`
|
||||
|
||||
### 5. `UR Mask Processor`
|
||||
|
||||
对 mask 做 remove 常用处理:
|
||||
|
||||
- 扩张
|
||||
- 高斯羽化
|
||||
- 预览叠加
|
||||
|
||||
### 6. `UR Keyframe Prep`
|
||||
|
||||
从图像批中选出关键帧,并准备 remove 输入:
|
||||
|
||||
- 选关键帧
|
||||
- 对应 mask 对齐
|
||||
- 按最长边缩放
|
||||
- pad 到指定倍数
|
||||
- 用填充色生成 `filled_frame`
|
||||
|
||||
其中 `filled_frame` 可以直接送进任意关键帧修复 / inpaint / edit 模型。
|
||||
|
||||
### 7. `UR Soft Composite`
|
||||
|
||||
一个不依赖 BrushNet 的软融合节点,用于把修复结果融合回原图 / 原视频帧批。
|
||||
|
||||
## 推荐工作流
|
||||
|
||||
> 这个扩展刻意只负责 remove 核心流程,不强绑某个检测器、分割器、视频模型或视频 IO 节点。
|
||||
|
||||
### A. 手工框选 remove
|
||||
|
||||
```text
|
||||
Frames/Image Batch
|
||||
-> UR Region Editor
|
||||
-> UR Regions To Mask Batch
|
||||
-> UR Mask Processor
|
||||
-> UR Keyframe Prep
|
||||
-> 关键帧修复模型
|
||||
-> (可选) 视频传播模型
|
||||
-> UR Soft Composite
|
||||
-> Output
|
||||
```
|
||||
|
||||
### B. 提示词驱动 remove
|
||||
|
||||
```text
|
||||
Frames/Image Batch
|
||||
-> UR Remove Prompt Builder
|
||||
-> 任意文本选目标模块(输出 MASK)
|
||||
-> UR Mask Processor
|
||||
-> UR Keyframe Prep
|
||||
-> 关键帧修复模型
|
||||
-> (可选) 视频传播模型
|
||||
-> UR Soft Composite
|
||||
-> Output
|
||||
```
|
||||
|
||||
## 为什么没有内置“文本直接找目标”
|
||||
|
||||
这是刻意的设计边界:
|
||||
|
||||
- remove 核心流程应该通用、可复用
|
||||
- 文本找目标属于检测 / 分割 / 跟踪能力
|
||||
- 这部分通常意味着额外模型、额外权重、额外依赖
|
||||
|
||||
因此当前版本先把 remove 主流程做成稳定、自包含、无外部节点硬依赖的核心层。
|
||||
|
||||
如果后续要继续扩展,最自然的下一步是在这个扩展内部新增:
|
||||
|
||||
- `UR Prompt Mask Locator`
|
||||
- `UR Video Track Mask`
|
||||
- `UR Model Bundle`
|
||||
|
||||
但这些应当作为后续迭代,而不是在 V1 就把依赖链做死。
|
||||
|
||||
## 安装方式
|
||||
|
||||
把整个目录放进:
|
||||
|
||||
- `ComfyUI/custom_nodes/ComfyUI-UniversalRemove`
|
||||
|
||||
然后重启 ComfyUI。
|
||||
|
||||
## 示例工作流
|
||||
|
||||
当前已提供一个可直接导入的最小工作流:
|
||||
|
||||
- `example_workflows/Universal Remove - Interactive Region Editor.json`
|
||||
|
||||
这个 workflow 的特点:
|
||||
|
||||
- 只依赖 ComfyUI core + 本扩展
|
||||
- 可直接导入并运行,不需要额外图片文件
|
||||
- 可用 `UR Region Editor` 交互绘制 `regions_json`
|
||||
- 也可以手工编辑 `regions_json`
|
||||
- 可预览:
|
||||
- mask preview
|
||||
- filled keyframe
|
||||
- soft composite result
|
||||
|
||||
这个示例是 **remove 核心流程示例**,不内置外部检测模型或修复模型,因此更适合作为:
|
||||
|
||||
- remove 管线起点
|
||||
- 接入你自己的 inpaint / edit / video propagation 模型前的可运行 scaffold
|
||||
|
||||
## 测试
|
||||
|
||||
仓库内已提供基础单元测试:
|
||||
|
||||
- `tests/test_universal_remove.py`
|
||||
|
||||
运行方式:
|
||||
|
||||
```bash
|
||||
uv pip install --python /path/to/ComfyUI/.venv/bin/python -r requirements-dev.txt
|
||||
/path/to/ComfyUI/.venv/bin/python -m pytest tests/test_universal_remove.py
|
||||
```
|
||||
|
||||
## 视频输入与关键帧圈选
|
||||
|
||||
当前扩展不内置视频读取器,但已经内置 `UR Region Editor` 前端交互式圈选 UI。
|
||||
|
||||
推荐接法是:
|
||||
|
||||
```text
|
||||
任意视频读取节点 / 你的脚本
|
||||
-> IMAGE frame batch
|
||||
-> UR Region Editor 输出 regions_json
|
||||
-> UR Regions To Mask Batch
|
||||
```
|
||||
|
||||
也就是说,本扩展支持的是:
|
||||
|
||||
- 输入视频拆帧后的 `IMAGE` 批次
|
||||
- 通过 `frame_index` 指定关键帧
|
||||
- 通过 `UR Region Editor` 鼠标绘制或手写 `regions_json` 描述关键帧上的圈选区域
|
||||
- 输出整段帧批可用的 `MASK`
|
||||
|
||||
`UR Region Editor` 的本地视频背景只用于交互定位,不替代实际视频读取节点;真正进入后端计算的是上游 `IMAGE` frame batch 和编辑器输出的 `regions_json`。
|
||||
|
||||
## region JSON 示例
|
||||
|
||||
### 单关键帧矩形 + 圆形
|
||||
|
||||
```json
|
||||
[
|
||||
{"type": "rect", "frame_index": 0, "x": 160, "y": 120, "w": 260, "h": 140},
|
||||
{"type": "circle", "frame_index": 0, "cx": 520, "cy": 250, "r": 70}
|
||||
]
|
||||
```
|
||||
|
||||
### 多关键帧多边形
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"type": "polygon",
|
||||
"frame_index": 0,
|
||||
"points": [[120, 160], [240, 150], [280, 260], [150, 290]]
|
||||
},
|
||||
{
|
||||
"type": "polygon",
|
||||
"frame_index": 48,
|
||||
"points": [[180, 170], [310, 160], [350, 280], [210, 310]]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
配合 `broadcast_mode=hold_until_next_keyframe` 时,第一个关键帧 mask 会使用到下一个关键帧之前,第二个关键帧 mask 会使用到结尾。
|
||||
|
||||
## bbox JSON 示例
|
||||
|
||||
### `xyxy`
|
||||
|
||||
```json
|
||||
[
|
||||
[100, 120, 420, 220],
|
||||
[520, 50, 700, 160]
|
||||
]
|
||||
```
|
||||
|
||||
### `xywh`
|
||||
|
||||
```json
|
||||
[
|
||||
{"x": 100, "y": 120, "w": 320, "h": 100},
|
||||
{"x": 520, "y": 50, "w": 180, "h": 110}
|
||||
]
|
||||
```
|
||||
|
||||
## 备注
|
||||
|
||||
这个扩展当前最适合做:
|
||||
|
||||
- 通用 remove 流程骨架
|
||||
- 去水印 / 去字幕 / 去 logo
|
||||
- 有上游 mask 时的角色移除 / 物体移除
|
||||
|
||||
如果你下一步要做“文本直接选目标并跟踪整段视频”,建议继续在这个目录里加新节点,而不是让核心 remove 节点反向依赖别的 custom nodes。
|
||||
@@ -0,0 +1,8 @@
|
||||
try:
|
||||
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
except ImportError:
|
||||
from nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
@@ -0,0 +1,723 @@
|
||||
{
|
||||
"revision": 0,
|
||||
"last_node_id": 9,
|
||||
"last_link_id": 12,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "UR Demo Image",
|
||||
"pos": [
|
||||
-980,
|
||||
260
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
480
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "batch_size",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
1,
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "UR Demo Image"
|
||||
},
|
||||
"widgets_values": [
|
||||
768,
|
||||
512,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "UR Regions To Mask Batch",
|
||||
"pos": [
|
||||
-460,
|
||||
230
|
||||
],
|
||||
"size": [
|
||||
360,
|
||||
320
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"name": "regions_json",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "regions_json"
|
||||
},
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"name": "default_frame_index",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "default_frame_index"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "broadcast_mode",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "broadcast_mode"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "normalized",
|
||||
"type": "BOOLEAN",
|
||||
"widget": {
|
||||
"name": "normalized"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "preview_frame",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "preview_frame"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "preview_color",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "preview_color"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "preview_opacity",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "preview_opacity"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "selected_frame",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "preview",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "UR Regions To Mask Batch"
|
||||
},
|
||||
"widgets_values": [
|
||||
"[{\"type\":\"rect\",\"frame_index\":0,\"x\":160,\"y\":120,\"w\":260,\"h\":140},{\"type\":\"circle\",\"frame_index\":0,\"cx\":520,\"cy\":250,\"r\":70}]",
|
||||
0,
|
||||
"repeat_to_batch",
|
||||
false,
|
||||
0,
|
||||
"#FF3B30",
|
||||
0.35
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "UR Mask Processor",
|
||||
"pos": [
|
||||
-10,
|
||||
230
|
||||
],
|
||||
"size": [
|
||||
330,
|
||||
260
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"name": "expand_pixels",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "expand_pixels"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "blur_sigma",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "blur_sigma"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "blur_kernel",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "blur_kernel"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "preview_color",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "preview_color"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "preview_opacity",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "preview_opacity"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
5,
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "mask_image",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "preview",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
11
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "UR Mask Processor"
|
||||
},
|
||||
"widgets_values": [
|
||||
16,
|
||||
4.0,
|
||||
0,
|
||||
"#00FF00",
|
||||
0.35
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "UR Keyframe Prep",
|
||||
"pos": [
|
||||
400,
|
||||
230
|
||||
],
|
||||
"size": [
|
||||
340,
|
||||
250
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"link": 5
|
||||
},
|
||||
{
|
||||
"name": "frame_index",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "frame_index"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "fill_color",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "fill_color"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "resize_longest_side",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "resize_longest_side"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "pad_to_multiple",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "pad_to_multiple"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "original_frame",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "prepared_frame",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "prepared_mask",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "filled_frame",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
8,
|
||||
9
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "prepared_width",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "prepared_height",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "frame_count",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "UR Keyframe Prep"
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
"#00FF00",
|
||||
0,
|
||||
16
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "UR Soft Composite",
|
||||
"pos": [
|
||||
840,
|
||||
230
|
||||
],
|
||||
"size": [
|
||||
300,
|
||||
160
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "inpaint",
|
||||
"type": "IMAGE",
|
||||
"link": 8
|
||||
},
|
||||
{
|
||||
"name": "original",
|
||||
"type": "IMAGE",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"name": "kernel",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "kernel"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "sigma",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "sigma"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "soft_mask",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "UR Soft Composite"
|
||||
},
|
||||
"widgets_values": [
|
||||
25,
|
||||
10.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
300,
|
||||
620
|
||||
],
|
||||
"size": [
|
||||
320,
|
||||
260
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 4,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
700,
|
||||
620
|
||||
],
|
||||
"size": [
|
||||
320,
|
||||
260
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 4,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
-40,
|
||||
620
|
||||
],
|
||||
"size": [
|
||||
320,
|
||||
260
|
||||
],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 4,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 11
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "UR Region Editor",
|
||||
"pos": [
|
||||
-900,
|
||||
-250
|
||||
],
|
||||
"size": [
|
||||
680,
|
||||
680
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "regions_json",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "regions_json"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "canvas_width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "canvas_width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "canvas_height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "canvas_height"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "default_frame_index",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "default_frame_index"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "regions_json",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
12
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "canvas_width",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "canvas_height",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "region_count",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "UR Region Editor"
|
||||
},
|
||||
"widgets_values": [
|
||||
"[{\"type\":\"rect\",\"frame_index\":0,\"x\":160,\"y\":120,\"w\":260,\"h\":140},{\"type\":\"circle\",\"frame_index\":0,\"cx\":520,\"cy\":250,\"r\":70}]",
|
||||
768,
|
||||
512,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
"MASK"
|
||||
],
|
||||
[
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
3,
|
||||
6,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
5,
|
||||
3,
|
||||
0,
|
||||
4,
|
||||
1,
|
||||
"MASK"
|
||||
],
|
||||
[
|
||||
6,
|
||||
4,
|
||||
0,
|
||||
5,
|
||||
1,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
7,
|
||||
3,
|
||||
0,
|
||||
5,
|
||||
2,
|
||||
"MASK"
|
||||
],
|
||||
[
|
||||
8,
|
||||
4,
|
||||
3,
|
||||
5,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
9,
|
||||
4,
|
||||
3,
|
||||
6,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
10,
|
||||
5,
|
||||
0,
|
||||
7,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
11,
|
||||
3,
|
||||
2,
|
||||
8,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
12,
|
||||
9,
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
"STRING"
|
||||
]
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,843 @@
|
||||
import json
|
||||
import math
|
||||
from typing import List, Sequence, Tuple
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
|
||||
CATEGORY = "Universal Remove"
|
||||
|
||||
|
||||
def _ensure_image_batch(images: torch.Tensor) -> torch.Tensor:
|
||||
if not isinstance(images, torch.Tensor):
|
||||
raise TypeError("Expected IMAGE input as torch.Tensor")
|
||||
if images.ndim == 3:
|
||||
images = images.unsqueeze(0)
|
||||
if images.ndim != 4:
|
||||
raise ValueError(f"Expected IMAGE tensor with 4 dims, got {images.ndim}")
|
||||
return images.clamp(0.0, 1.0)
|
||||
|
||||
|
||||
def _ensure_mask_batch(mask: torch.Tensor) -> torch.Tensor:
|
||||
if not isinstance(mask, torch.Tensor):
|
||||
raise TypeError("Expected MASK input as torch.Tensor")
|
||||
if mask.ndim == 2:
|
||||
mask = mask.unsqueeze(0)
|
||||
elif mask.ndim == 4 and mask.shape[1] == 1:
|
||||
mask = mask[:, 0]
|
||||
elif mask.ndim == 4 and mask.shape[-1] == 1:
|
||||
mask = mask[..., 0]
|
||||
if mask.ndim != 3:
|
||||
raise ValueError(f"Expected MASK tensor with 3 dims, got {mask.ndim}")
|
||||
return mask.float().clamp(0.0, 1.0)
|
||||
|
||||
|
||||
def _tile_to_batch(tensor: torch.Tensor, target_batch: int) -> torch.Tensor:
|
||||
if tensor.shape[0] == target_batch:
|
||||
return tensor
|
||||
if tensor.shape[0] <= 0:
|
||||
raise ValueError("Cannot tile an empty batch")
|
||||
repeats = math.ceil(target_batch / tensor.shape[0])
|
||||
repeat_shape = [repeats] + [1] * (tensor.ndim - 1)
|
||||
return tensor.repeat(*repeat_shape)[:target_batch]
|
||||
|
||||
|
||||
def _match_mask_to_images(mask: torch.Tensor, images: torch.Tensor) -> torch.Tensor:
|
||||
mask = _ensure_mask_batch(mask)
|
||||
images = _ensure_image_batch(images)
|
||||
if mask.shape[0] != images.shape[0]:
|
||||
mask = _tile_to_batch(mask, images.shape[0])
|
||||
if mask.shape[1] != images.shape[1] or mask.shape[2] != images.shape[2]:
|
||||
mask = F.interpolate(
|
||||
mask.unsqueeze(1),
|
||||
size=(images.shape[1], images.shape[2]),
|
||||
mode="nearest",
|
||||
).squeeze(1)
|
||||
return mask.clamp(0.0, 1.0)
|
||||
|
||||
|
||||
def _to_bchw(images: torch.Tensor) -> torch.Tensor:
|
||||
return images.permute(0, 3, 1, 2)
|
||||
|
||||
|
||||
def _to_bhwc(images: torch.Tensor) -> torch.Tensor:
|
||||
return images.permute(0, 2, 3, 1)
|
||||
|
||||
|
||||
def _resize_images(images: torch.Tensor, size: Tuple[int, int]) -> torch.Tensor:
|
||||
bchw = _to_bchw(images)
|
||||
resized = F.interpolate(bchw, size=size, mode="bilinear", align_corners=False)
|
||||
return _to_bhwc(resized).clamp(0.0, 1.0)
|
||||
|
||||
|
||||
def _resize_masks(mask: torch.Tensor, size: Tuple[int, int]) -> torch.Tensor:
|
||||
resized = F.interpolate(mask.unsqueeze(1), size=size, mode="nearest")
|
||||
return resized.squeeze(1).clamp(0.0, 1.0)
|
||||
|
||||
|
||||
def _pad_images(images: torch.Tensor, target_h: int, target_w: int) -> torch.Tensor:
|
||||
pad_h = max(0, target_h - images.shape[1])
|
||||
pad_w = max(0, target_w - images.shape[2])
|
||||
if pad_h == 0 and pad_w == 0:
|
||||
return images
|
||||
bchw = _to_bchw(images)
|
||||
padded = F.pad(bchw, (0, pad_w, 0, pad_h), mode="constant", value=0.0)
|
||||
return _to_bhwc(padded).clamp(0.0, 1.0)
|
||||
|
||||
|
||||
def _pad_masks(mask: torch.Tensor, target_h: int, target_w: int) -> torch.Tensor:
|
||||
pad_h = max(0, target_h - mask.shape[1])
|
||||
pad_w = max(0, target_w - mask.shape[2])
|
||||
if pad_h == 0 and pad_w == 0:
|
||||
return mask
|
||||
padded = F.pad(mask.unsqueeze(1), (0, pad_w, 0, pad_h), mode="constant", value=0.0)
|
||||
return padded.squeeze(1).clamp(0.0, 1.0)
|
||||
|
||||
|
||||
def _parse_hex_color(color: str) -> torch.Tensor:
|
||||
text = color.strip()
|
||||
if text.startswith("#"):
|
||||
text = text[1:]
|
||||
if len(text) != 6:
|
||||
raise ValueError("Color must be a 6-digit hex string, e.g. #00FF00")
|
||||
rgb = [int(text[i : i + 2], 16) / 255.0 for i in range(0, 6, 2)]
|
||||
return torch.tensor(rgb, dtype=torch.float32)
|
||||
|
||||
|
||||
def _overlay_mask(images: torch.Tensor, mask: torch.Tensor, color: torch.Tensor, opacity: float) -> torch.Tensor:
|
||||
images = _ensure_image_batch(images)
|
||||
mask = _match_mask_to_images(mask, images)
|
||||
color = color.to(device=images.device, dtype=images.dtype).view(1, 1, 1, 3)
|
||||
alpha = mask.unsqueeze(-1).clamp(0.0, 1.0) * float(opacity)
|
||||
return (images * (1.0 - alpha) + color * alpha).clamp(0.0, 1.0)
|
||||
|
||||
|
||||
def _gaussian_kernel_1d(kernel_size: int, sigma: float, device: torch.device, dtype: torch.dtype) -> torch.Tensor:
|
||||
if kernel_size % 2 == 0:
|
||||
kernel_size += 1
|
||||
radius = kernel_size // 2
|
||||
if sigma <= 0:
|
||||
sigma = max(float(kernel_size) / 6.0, 1e-6)
|
||||
coords = torch.arange(-radius, radius + 1, device=device, dtype=dtype)
|
||||
kernel = torch.exp(-0.5 * (coords / sigma) ** 2)
|
||||
kernel = kernel / kernel.sum()
|
||||
return kernel
|
||||
|
||||
|
||||
def _gaussian_blur_mask(mask: torch.Tensor, kernel_size: int, sigma: float) -> torch.Tensor:
|
||||
mask = _ensure_mask_batch(mask)
|
||||
if kernel_size <= 1 and sigma <= 0:
|
||||
return mask
|
||||
if kernel_size <= 1:
|
||||
kernel_size = int(max(3, math.ceil(sigma * 6)))
|
||||
if kernel_size % 2 == 0:
|
||||
kernel_size += 1
|
||||
|
||||
kernel_1d = _gaussian_kernel_1d(kernel_size, sigma, mask.device, mask.dtype)
|
||||
kernel_x = kernel_1d.view(1, 1, 1, -1)
|
||||
kernel_y = kernel_1d.view(1, 1, -1, 1)
|
||||
|
||||
blurred = mask.unsqueeze(1)
|
||||
pad = kernel_size // 2
|
||||
blurred = F.pad(blurred, (pad, pad, 0, 0), mode="replicate")
|
||||
blurred = F.conv2d(blurred, kernel_x)
|
||||
blurred = F.pad(blurred, (0, 0, pad, pad), mode="replicate")
|
||||
blurred = F.conv2d(blurred, kernel_y)
|
||||
return blurred.squeeze(1).clamp(0.0, 1.0)
|
||||
|
||||
|
||||
def _expand_mask(mask: torch.Tensor, expand_pixels: int) -> torch.Tensor:
|
||||
mask = _ensure_mask_batch(mask)
|
||||
if expand_pixels <= 0:
|
||||
return mask
|
||||
kernel = expand_pixels * 2 + 1
|
||||
expanded = F.max_pool2d(mask.unsqueeze(1), kernel_size=kernel, stride=1, padding=expand_pixels)
|
||||
return expanded.squeeze(1).clamp(0.0, 1.0)
|
||||
|
||||
|
||||
def _fill_masked_region(images: torch.Tensor, mask: torch.Tensor, color: torch.Tensor) -> torch.Tensor:
|
||||
images = _ensure_image_batch(images)
|
||||
mask = _match_mask_to_images(mask, images)
|
||||
color = color.to(device=images.device, dtype=images.dtype).view(1, 1, 1, 3)
|
||||
return (images * (1.0 - mask.unsqueeze(-1)) + color * mask.unsqueeze(-1)).clamp(0.0, 1.0)
|
||||
|
||||
|
||||
def _normalize_box_values(values: Sequence[float], width: int, height: int, normalized: bool) -> Tuple[float, float, float, float]:
|
||||
x1, y1, x2, y2 = values
|
||||
if normalized:
|
||||
x1 *= width
|
||||
x2 *= width
|
||||
y1 *= height
|
||||
y2 *= height
|
||||
return x1, y1, x2, y2
|
||||
|
||||
|
||||
def _parse_single_box(item, coordinate_format: str, width: int, height: int, normalized: bool) -> Tuple[int, int, int, int]:
|
||||
if isinstance(item, dict):
|
||||
if {"x", "y", "w", "h"}.issubset(item):
|
||||
x1 = float(item["x"])
|
||||
y1 = float(item["y"])
|
||||
w = float(item["w"])
|
||||
h = float(item["h"])
|
||||
if normalized:
|
||||
x1 *= width
|
||||
y1 *= height
|
||||
w *= width
|
||||
h *= height
|
||||
x2 = x1 + w
|
||||
y2 = y1 + h
|
||||
elif {"x1", "y1", "x2", "y2"}.issubset(item):
|
||||
x1, y1, x2, y2 = _normalize_box_values(
|
||||
[float(item["x1"]), float(item["y1"]), float(item["x2"]), float(item["y2"])],
|
||||
width,
|
||||
height,
|
||||
normalized,
|
||||
)
|
||||
elif {"left", "top", "right", "bottom"}.issubset(item):
|
||||
x1, y1, x2, y2 = _normalize_box_values(
|
||||
[float(item["left"]), float(item["top"]), float(item["right"]), float(item["bottom"])],
|
||||
width,
|
||||
height,
|
||||
normalized,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported box object: {item}")
|
||||
elif isinstance(item, (list, tuple)) and len(item) == 4:
|
||||
values = [float(v) for v in item]
|
||||
if coordinate_format == "xywh":
|
||||
x1, y1, w, h = values
|
||||
if normalized:
|
||||
x1 *= width
|
||||
y1 *= height
|
||||
w *= width
|
||||
h *= height
|
||||
x2 = x1 + w
|
||||
y2 = y1 + h
|
||||
else:
|
||||
x1, y1, x2, y2 = _normalize_box_values(values, width, height, normalized)
|
||||
else:
|
||||
raise ValueError(f"Unsupported box entry: {item}")
|
||||
|
||||
left = int(math.floor(min(x1, x2)))
|
||||
right = int(math.ceil(max(x1, x2)))
|
||||
top = int(math.floor(min(y1, y2)))
|
||||
bottom = int(math.ceil(max(y1, y2)))
|
||||
|
||||
left = max(0, min(width, left))
|
||||
right = max(0, min(width, right))
|
||||
top = max(0, min(height, top))
|
||||
bottom = max(0, min(height, bottom))
|
||||
return left, top, right, bottom
|
||||
|
||||
|
||||
def _parse_boxes_json(boxes_json: str, coordinate_format: str, width: int, height: int, normalized: bool) -> List[Tuple[int, int, int, int]]:
|
||||
text = boxes_json.strip()
|
||||
if not text:
|
||||
return []
|
||||
data = json.loads(text)
|
||||
if isinstance(data, dict):
|
||||
if "boxes" in data:
|
||||
data = data["boxes"]
|
||||
else:
|
||||
data = [data]
|
||||
if not isinstance(data, list):
|
||||
raise ValueError("boxes_json must be a JSON array or object")
|
||||
boxes = []
|
||||
for item in data:
|
||||
box = _parse_single_box(item, coordinate_format, width, height, normalized)
|
||||
if box[2] > box[0] and box[3] > box[1]:
|
||||
boxes.append(box)
|
||||
return boxes
|
||||
|
||||
|
||||
def _scale_point(x: float, y: float, width: int, height: int, normalized: bool) -> Tuple[float, float]:
|
||||
if normalized:
|
||||
x *= width
|
||||
y *= height
|
||||
return x, y
|
||||
|
||||
|
||||
def _region_frame_index(region: dict, default_frame_index: int, batch_size: int) -> int:
|
||||
index = int(region.get("frame_index", default_frame_index))
|
||||
return max(0, min(index, batch_size - 1))
|
||||
|
||||
|
||||
def _region_type(region: dict) -> str:
|
||||
value = str(region.get("type", region.get("shape", "rect"))).strip().lower()
|
||||
aliases = {
|
||||
"rectangle": "rect",
|
||||
"box": "rect",
|
||||
"bbox": "rect",
|
||||
"ellipse": "circle",
|
||||
"poly": "polygon",
|
||||
}
|
||||
return aliases.get(value, value)
|
||||
|
||||
|
||||
def _polygon_to_mask(points: Sequence[Sequence[float]], width: int, height: int, device: torch.device, dtype: torch.dtype) -> torch.Tensor:
|
||||
if len(points) < 3:
|
||||
return torch.zeros((height, width), dtype=dtype, device=device)
|
||||
|
||||
px = torch.arange(width, dtype=dtype, device=device).view(1, width) + 0.5
|
||||
py = torch.arange(height, dtype=dtype, device=device).view(height, 1) + 0.5
|
||||
inside = torch.zeros((height, width), dtype=torch.bool, device=device)
|
||||
|
||||
wrapped = list(points) + [points[0]]
|
||||
eps = torch.tensor(1e-6, dtype=dtype, device=device)
|
||||
for current, nxt in zip(wrapped[:-1], wrapped[1:]):
|
||||
x1 = torch.tensor(float(current[0]), dtype=dtype, device=device)
|
||||
y1 = torch.tensor(float(current[1]), dtype=dtype, device=device)
|
||||
x2 = torch.tensor(float(nxt[0]), dtype=dtype, device=device)
|
||||
y2 = torch.tensor(float(nxt[1]), dtype=dtype, device=device)
|
||||
crosses_y = torch.logical_xor(y1 > py, y2 > py)
|
||||
x_intersection = (x2 - x1) * (py - y1) / (y2 - y1 + eps) + x1
|
||||
intersects = crosses_y & (px < x_intersection)
|
||||
inside = torch.logical_xor(inside, intersects)
|
||||
|
||||
return inside.to(dtype=dtype)
|
||||
|
||||
|
||||
def _draw_region(mask: torch.Tensor, region: dict, width: int, height: int, normalized: bool) -> None:
|
||||
shape = _region_type(region)
|
||||
if shape == "rect":
|
||||
box = _parse_single_box(region, "xyxy", width, height, normalized)
|
||||
x1, y1, x2, y2 = box
|
||||
if x2 > x1 and y2 > y1:
|
||||
mask[y1:y2, x1:x2] = 1.0
|
||||
return
|
||||
|
||||
if shape == "circle":
|
||||
if "center" in region:
|
||||
cx, cy = region["center"]
|
||||
else:
|
||||
cx = region.get("cx", region.get("x", 0.0))
|
||||
cy = region.get("cy", region.get("y", 0.0))
|
||||
cx, cy = _scale_point(float(cx), float(cy), width, height, normalized)
|
||||
radius = float(region.get("r", region.get("radius", 0.0)))
|
||||
if normalized:
|
||||
radius *= min(width, height)
|
||||
if radius <= 0:
|
||||
return
|
||||
yy, xx = torch.meshgrid(
|
||||
torch.arange(height, dtype=mask.dtype, device=mask.device),
|
||||
torch.arange(width, dtype=mask.dtype, device=mask.device),
|
||||
indexing="ij",
|
||||
)
|
||||
circle = (xx + 0.5 - cx) ** 2 + (yy + 0.5 - cy) ** 2 <= radius ** 2
|
||||
mask[circle] = 1.0
|
||||
return
|
||||
|
||||
if shape == "polygon":
|
||||
raw_points = region.get("points", [])
|
||||
points = []
|
||||
for point in raw_points:
|
||||
if isinstance(point, dict):
|
||||
x = float(point.get("x", 0.0))
|
||||
y = float(point.get("y", 0.0))
|
||||
else:
|
||||
x = float(point[0])
|
||||
y = float(point[1])
|
||||
points.append(_scale_point(x, y, width, height, normalized))
|
||||
mask.copy_(torch.maximum(mask, _polygon_to_mask(points, width, height, mask.device, mask.dtype)))
|
||||
return
|
||||
|
||||
raise ValueError(f"Unsupported region type: {shape}")
|
||||
|
||||
|
||||
def _parse_regions_json(regions_json: str) -> List[dict]:
|
||||
text = regions_json.strip()
|
||||
if not text:
|
||||
return []
|
||||
data = json.loads(text)
|
||||
if isinstance(data, dict):
|
||||
if "regions" in data:
|
||||
data = data["regions"]
|
||||
else:
|
||||
data = [data]
|
||||
if not isinstance(data, list):
|
||||
raise ValueError("regions_json must be a JSON array or object")
|
||||
for region in data:
|
||||
if not isinstance(region, dict):
|
||||
raise ValueError(f"Region entries must be objects, got: {region}")
|
||||
return data
|
||||
|
||||
|
||||
def _apply_keyframe_masks(keyframe_masks: dict, batch: int, height: int, width: int, dtype: torch.dtype, device: torch.device, broadcast_mode: str) -> torch.Tensor:
|
||||
mask = torch.zeros((batch, height, width), dtype=dtype, device=device)
|
||||
if not keyframe_masks:
|
||||
return mask
|
||||
|
||||
sorted_items = sorted(keyframe_masks.items())
|
||||
if broadcast_mode == "repeat_to_batch":
|
||||
combined = torch.zeros((height, width), dtype=dtype, device=device)
|
||||
for _, key_mask in sorted_items:
|
||||
combined = torch.maximum(combined, key_mask)
|
||||
return combined.unsqueeze(0).repeat(batch, 1, 1)
|
||||
|
||||
if broadcast_mode == "hold_until_next_keyframe":
|
||||
for idx, (start_frame, key_mask) in enumerate(sorted_items):
|
||||
end_frame = sorted_items[idx + 1][0] if idx + 1 < len(sorted_items) else batch
|
||||
if end_frame > start_frame:
|
||||
mask[start_frame:end_frame] = key_mask
|
||||
return mask
|
||||
|
||||
for frame, key_mask in sorted_items:
|
||||
if broadcast_mode == "hold_after_frame":
|
||||
mask[frame:] = key_mask
|
||||
else:
|
||||
mask[frame] = key_mask
|
||||
return mask
|
||||
|
||||
|
||||
class URDemoImage:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"width": ("INT", {"default": 768, "min": 64, "max": 8192, "step": 8}),
|
||||
"height": ("INT", {"default": 512, "min": 64, "max": 8192, "step": 8}),
|
||||
"batch_size": ("INT", {"default": 1, "min": 1, "max": 256}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = CATEGORY
|
||||
|
||||
def generate(self, width: int, height: int, batch_size: int):
|
||||
x = torch.linspace(0.0, 1.0, int(width), dtype=torch.float32).view(1, 1, width)
|
||||
y = torch.linspace(0.0, 1.0, int(height), dtype=torch.float32).view(1, height, 1)
|
||||
red = 0.15 + 0.35 * x.expand(1, height, width)
|
||||
green = 0.18 + 0.45 * y.expand(1, height, width)
|
||||
blue = 0.25 + 0.25 * (1.0 - x.expand(1, height, width))
|
||||
image = torch.stack([red, green, blue], dim=-1).repeat(int(batch_size), 1, 1, 1)
|
||||
|
||||
x1 = max(0, min(int(width), int(width * 0.22)))
|
||||
x2 = max(0, min(int(width), int(width * 0.55)))
|
||||
y1 = max(0, min(int(height), int(height * 0.24)))
|
||||
y2 = max(0, min(int(height), int(height * 0.50)))
|
||||
image[:, y1:y2, x1:x2, 0] = 0.92
|
||||
image[:, y1:y2, x1:x2, 1] = 0.16
|
||||
image[:, y1:y2, x1:x2, 2] = 0.12
|
||||
return (image.clamp(0.0, 1.0),)
|
||||
|
||||
|
||||
class URRemovePromptBuilder:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"target_prompt": ("STRING", {"multiline": False, "default": "object to remove"}),
|
||||
"scene_hint": ("STRING", {"multiline": True, "default": ""}),
|
||||
"preserve_prompt": ("STRING", {"multiline": True, "default": ""}),
|
||||
"extra_negative_prompt": ("STRING", {"multiline": True, "default": ""}),
|
||||
"fill_mode": (["background_reconstruct", "clean_plate", "direct_replace"],),
|
||||
"masked_edit_only": ("BOOLEAN", {"default": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING", "STRING")
|
||||
RETURN_NAMES = ("segmentation_prompt", "remove_positive_prompt", "remove_negative_prompt")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = CATEGORY
|
||||
|
||||
def build(
|
||||
self,
|
||||
target_prompt: str,
|
||||
scene_hint: str,
|
||||
preserve_prompt: str,
|
||||
extra_negative_prompt: str,
|
||||
fill_mode: str,
|
||||
masked_edit_only: bool,
|
||||
):
|
||||
target = target_prompt.strip() or "target object"
|
||||
segmentation_prompt = target
|
||||
|
||||
positive_parts = [f"Remove {target} from the masked region."]
|
||||
if fill_mode == "background_reconstruct":
|
||||
positive_parts.append("Reconstruct a plausible background behind the removed content.")
|
||||
elif fill_mode == "clean_plate":
|
||||
positive_parts.append("Create a clean plate result that matches the surrounding scene.")
|
||||
else:
|
||||
positive_parts.append("Replace the masked region with visually coherent scene content.")
|
||||
|
||||
if masked_edit_only:
|
||||
positive_parts.append("Only modify the masked region and keep unmasked content unchanged.")
|
||||
if scene_hint.strip():
|
||||
positive_parts.append(f"Scene context: {scene_hint.strip()}.")
|
||||
if preserve_prompt.strip():
|
||||
positive_parts.append(f"Preserve: {preserve_prompt.strip()}.")
|
||||
|
||||
negative_parts = [
|
||||
"artifacts",
|
||||
"ghosting",
|
||||
"duplicate subject",
|
||||
"warped anatomy",
|
||||
"broken perspective",
|
||||
"extra text",
|
||||
"watermark",
|
||||
"subtitle",
|
||||
"logo",
|
||||
"blur",
|
||||
"low quality",
|
||||
]
|
||||
if extra_negative_prompt.strip():
|
||||
negative_parts.append(extra_negative_prompt.strip())
|
||||
|
||||
return (
|
||||
segmentation_prompt,
|
||||
" ".join(positive_parts),
|
||||
", ".join(negative_parts),
|
||||
)
|
||||
|
||||
|
||||
class URRegionEditor:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"regions_json": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": True,
|
||||
"default": "[]",
|
||||
},
|
||||
),
|
||||
"canvas_width": ("INT", {"default": 768, "min": 64, "max": 8192, "step": 8}),
|
||||
"canvas_height": ("INT", {"default": 512, "min": 64, "max": 8192, "step": 8}),
|
||||
"default_frame_index": ("INT", {"default": 0, "min": 0, "max": 1000000}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "INT", "INT", "INT")
|
||||
RETURN_NAMES = ("regions_json", "canvas_width", "canvas_height", "region_count")
|
||||
FUNCTION = "emit"
|
||||
CATEGORY = CATEGORY
|
||||
|
||||
def emit(self, regions_json: str, canvas_width: int, canvas_height: int, default_frame_index: int):
|
||||
regions = _parse_regions_json(regions_json)
|
||||
normalized_regions = []
|
||||
for region in regions:
|
||||
copied = dict(region)
|
||||
copied["frame_index"] = int(copied.get("frame_index", default_frame_index))
|
||||
normalized_regions.append(copied)
|
||||
text = json.dumps(normalized_regions, ensure_ascii=False)
|
||||
return (text, int(canvas_width), int(canvas_height), len(normalized_regions))
|
||||
|
||||
|
||||
class URBoxesToMaskBatch:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"boxes_json": ("STRING", {"multiline": True, "default": "[]"}),
|
||||
"frame_index": ("INT", {"default": 0, "min": 0, "max": 1000000}),
|
||||
"coordinate_format": (["xyxy", "xywh"],),
|
||||
"broadcast_mode": (["repeat_to_batch", "single_frame_only", "hold_after_frame"],),
|
||||
"normalized": ("BOOLEAN", {"default": False}),
|
||||
"preview_color": ("STRING", {"multiline": False, "default": "#FF3B30"}),
|
||||
"preview_opacity": ("FLOAT", {"default": 0.35, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MASK", "IMAGE", "IMAGE")
|
||||
RETURN_NAMES = ("mask", "selected_frame", "preview")
|
||||
FUNCTION = "build_masks"
|
||||
CATEGORY = CATEGORY
|
||||
|
||||
def build_masks(
|
||||
self,
|
||||
images: torch.Tensor,
|
||||
boxes_json: str,
|
||||
frame_index: int,
|
||||
coordinate_format: str,
|
||||
broadcast_mode: str,
|
||||
normalized: bool,
|
||||
preview_color: str,
|
||||
preview_opacity: float,
|
||||
):
|
||||
images = _ensure_image_batch(images)
|
||||
batch, height, width, _ = images.shape
|
||||
index = max(0, min(int(frame_index), batch - 1))
|
||||
|
||||
boxes = _parse_boxes_json(boxes_json, coordinate_format, width, height, normalized)
|
||||
selected_mask = torch.zeros((1, height, width), dtype=images.dtype, device=images.device)
|
||||
for x1, y1, x2, y2 in boxes:
|
||||
selected_mask[:, y1:y2, x1:x2] = 1.0
|
||||
|
||||
if broadcast_mode == "repeat_to_batch":
|
||||
mask = selected_mask.repeat(batch, 1, 1)
|
||||
elif broadcast_mode == "hold_after_frame":
|
||||
mask = torch.zeros((batch, height, width), dtype=images.dtype, device=images.device)
|
||||
mask[index:] = selected_mask
|
||||
else:
|
||||
mask = torch.zeros((batch, height, width), dtype=images.dtype, device=images.device)
|
||||
mask[index] = selected_mask[0]
|
||||
|
||||
preview = _overlay_mask(
|
||||
images[index : index + 1],
|
||||
selected_mask,
|
||||
_parse_hex_color(preview_color),
|
||||
preview_opacity,
|
||||
)
|
||||
return (mask, images[index : index + 1], preview)
|
||||
|
||||
|
||||
class URRegionsToMaskBatch:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"regions_json": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": True,
|
||||
"default": '[{"type":"rect","frame_index":0,"x":160,"y":120,"w":260,"h":140}]',
|
||||
},
|
||||
),
|
||||
"default_frame_index": ("INT", {"default": 0, "min": 0, "max": 1000000}),
|
||||
"broadcast_mode": (["repeat_to_batch", "single_frame_only", "hold_after_frame", "hold_until_next_keyframe"],),
|
||||
"normalized": ("BOOLEAN", {"default": False}),
|
||||
"preview_frame": ("INT", {"default": 0, "min": 0, "max": 1000000}),
|
||||
"preview_color": ("STRING", {"multiline": False, "default": "#FF3B30"}),
|
||||
"preview_opacity": ("FLOAT", {"default": 0.35, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MASK", "IMAGE", "IMAGE")
|
||||
RETURN_NAMES = ("mask", "selected_frame", "preview")
|
||||
FUNCTION = "build_masks"
|
||||
CATEGORY = CATEGORY
|
||||
|
||||
def build_masks(
|
||||
self,
|
||||
images: torch.Tensor,
|
||||
regions_json: str,
|
||||
default_frame_index: int,
|
||||
broadcast_mode: str,
|
||||
normalized: bool,
|
||||
preview_frame: int,
|
||||
preview_color: str,
|
||||
preview_opacity: float,
|
||||
):
|
||||
images = _ensure_image_batch(images)
|
||||
batch, height, width, _ = images.shape
|
||||
default_index = max(0, min(int(default_frame_index), batch - 1))
|
||||
preview_index = max(0, min(int(preview_frame), batch - 1))
|
||||
|
||||
keyframe_masks = {}
|
||||
for region in _parse_regions_json(regions_json):
|
||||
frame = _region_frame_index(region, default_index, batch)
|
||||
if frame not in keyframe_masks:
|
||||
keyframe_masks[frame] = torch.zeros((height, width), dtype=images.dtype, device=images.device)
|
||||
_draw_region(keyframe_masks[frame], region, width, height, normalized)
|
||||
|
||||
mask = _apply_keyframe_masks(
|
||||
keyframe_masks,
|
||||
batch,
|
||||
height,
|
||||
width,
|
||||
images.dtype,
|
||||
images.device,
|
||||
broadcast_mode,
|
||||
)
|
||||
preview = _overlay_mask(
|
||||
images[preview_index : preview_index + 1],
|
||||
mask[preview_index : preview_index + 1],
|
||||
_parse_hex_color(preview_color),
|
||||
preview_opacity,
|
||||
)
|
||||
return (mask, images[preview_index : preview_index + 1], preview)
|
||||
|
||||
|
||||
class URMaskProcessor:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"mask": ("MASK",),
|
||||
"expand_pixels": ("INT", {"default": 0, "min": 0, "max": 2048}),
|
||||
"blur_sigma": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 512.0, "step": 0.01}),
|
||||
"blur_kernel": ("INT", {"default": 0, "min": 0, "max": 2049}),
|
||||
"preview_color": ("STRING", {"multiline": False, "default": "#00FF00"}),
|
||||
"preview_opacity": ("FLOAT", {"default": 0.35, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||
},
|
||||
"optional": {
|
||||
"image": ("IMAGE",),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MASK", "IMAGE", "IMAGE")
|
||||
RETURN_NAMES = ("mask", "mask_image", "preview")
|
||||
FUNCTION = "process"
|
||||
CATEGORY = CATEGORY
|
||||
|
||||
def process(
|
||||
self,
|
||||
mask: torch.Tensor,
|
||||
expand_pixels: int,
|
||||
blur_sigma: float,
|
||||
blur_kernel: int,
|
||||
preview_color: str,
|
||||
preview_opacity: float,
|
||||
image: torch.Tensor = None,
|
||||
):
|
||||
mask = _ensure_mask_batch(mask)
|
||||
if expand_pixels > 0:
|
||||
mask = _expand_mask(mask, expand_pixels)
|
||||
if blur_sigma > 0.0 or blur_kernel > 1:
|
||||
mask = _gaussian_blur_mask(mask, blur_kernel, blur_sigma)
|
||||
mask = mask.clamp(0.0, 1.0)
|
||||
|
||||
mask_image = mask.unsqueeze(-1).repeat(1, 1, 1, 3)
|
||||
if image is not None:
|
||||
image = _ensure_image_batch(image)
|
||||
preview = _overlay_mask(image, mask, _parse_hex_color(preview_color), preview_opacity)
|
||||
else:
|
||||
preview = _overlay_mask(mask_image, mask, _parse_hex_color(preview_color), preview_opacity)
|
||||
return (mask, mask_image, preview)
|
||||
|
||||
|
||||
class URKeyframePrep:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"mask": ("MASK",),
|
||||
"frame_index": ("INT", {"default": 0, "min": 0, "max": 1000000}),
|
||||
"fill_color": ("STRING", {"multiline": False, "default": "#00FF00"}),
|
||||
"resize_longest_side": ("INT", {"default": 0, "min": 0, "max": 8192, "step": 8}),
|
||||
"pad_to_multiple": ("INT", {"default": 16, "min": 1, "max": 256}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "IMAGE", "MASK", "IMAGE", "INT", "INT", "INT")
|
||||
RETURN_NAMES = (
|
||||
"original_frame",
|
||||
"prepared_frame",
|
||||
"prepared_mask",
|
||||
"filled_frame",
|
||||
"prepared_width",
|
||||
"prepared_height",
|
||||
"frame_count",
|
||||
)
|
||||
FUNCTION = "prepare"
|
||||
CATEGORY = CATEGORY
|
||||
|
||||
def prepare(
|
||||
self,
|
||||
images: torch.Tensor,
|
||||
mask: torch.Tensor,
|
||||
frame_index: int,
|
||||
fill_color: str,
|
||||
resize_longest_side: int,
|
||||
pad_to_multiple: int,
|
||||
):
|
||||
images = _ensure_image_batch(images)
|
||||
mask = _match_mask_to_images(mask, images)
|
||||
batch, height, width, _ = images.shape
|
||||
index = max(0, min(int(frame_index), batch - 1))
|
||||
|
||||
original_frame = images[index : index + 1]
|
||||
prepared_frame = original_frame.clone()
|
||||
prepared_mask = mask[index : index + 1].clone()
|
||||
|
||||
if resize_longest_side > 0:
|
||||
longest = max(height, width)
|
||||
scale = float(resize_longest_side) / float(longest)
|
||||
target_h = max(1, int(round(height * scale)))
|
||||
target_w = max(1, int(round(width * scale)))
|
||||
prepared_frame = _resize_images(prepared_frame, (target_h, target_w))
|
||||
prepared_mask = _resize_masks(prepared_mask, (target_h, target_w))
|
||||
|
||||
prepared_h = prepared_frame.shape[1]
|
||||
prepared_w = prepared_frame.shape[2]
|
||||
if pad_to_multiple > 1:
|
||||
target_h = int(math.ceil(prepared_h / pad_to_multiple) * pad_to_multiple)
|
||||
target_w = int(math.ceil(prepared_w / pad_to_multiple) * pad_to_multiple)
|
||||
prepared_frame = _pad_images(prepared_frame, target_h, target_w)
|
||||
prepared_mask = _pad_masks(prepared_mask, target_h, target_w)
|
||||
|
||||
color = _parse_hex_color(fill_color)
|
||||
filled_frame = _fill_masked_region(prepared_frame, prepared_mask, color)
|
||||
return (
|
||||
original_frame,
|
||||
prepared_frame,
|
||||
prepared_mask,
|
||||
filled_frame,
|
||||
prepared_frame.shape[2],
|
||||
prepared_frame.shape[1],
|
||||
batch,
|
||||
)
|
||||
|
||||
|
||||
class URSoftComposite:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"inpaint": ("IMAGE",),
|
||||
"original": ("IMAGE",),
|
||||
"mask": ("MASK",),
|
||||
"kernel": ("INT", {"default": 15, "min": 1, "max": 2049}),
|
||||
"sigma": ("FLOAT", {"default": 7.5, "min": 0.0, "max": 512.0, "step": 0.01}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("image", "soft_mask")
|
||||
FUNCTION = "composite"
|
||||
CATEGORY = CATEGORY
|
||||
|
||||
def composite(
|
||||
self,
|
||||
inpaint: torch.Tensor,
|
||||
original: torch.Tensor,
|
||||
mask: torch.Tensor,
|
||||
kernel: int,
|
||||
sigma: float,
|
||||
):
|
||||
original = _ensure_image_batch(original)
|
||||
inpaint = _ensure_image_batch(inpaint)
|
||||
target_batch = max(original.shape[0], inpaint.shape[0], _ensure_mask_batch(mask).shape[0])
|
||||
|
||||
original = _tile_to_batch(original, target_batch)
|
||||
inpaint = _tile_to_batch(inpaint, target_batch)
|
||||
mask = _tile_to_batch(_ensure_mask_batch(mask), target_batch)
|
||||
mask = _match_mask_to_images(mask, original)
|
||||
|
||||
if inpaint.shape[1] != original.shape[1] or inpaint.shape[2] != original.shape[2]:
|
||||
inpaint = _resize_images(inpaint, (original.shape[1], original.shape[2]))
|
||||
|
||||
soft_mask = _gaussian_blur_mask(mask, kernel, sigma).clamp(0.0, 1.0)
|
||||
composited = original * (1.0 - soft_mask.unsqueeze(-1)) + inpaint * soft_mask.unsqueeze(-1)
|
||||
return (composited.clamp(0.0, 1.0), soft_mask)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"UR Demo Image": URDemoImage,
|
||||
"UR Remove Prompt Builder": URRemovePromptBuilder,
|
||||
"UR Region Editor": URRegionEditor,
|
||||
"UR Boxes To Mask Batch": URBoxesToMaskBatch,
|
||||
"UR Regions To Mask Batch": URRegionsToMaskBatch,
|
||||
"UR Mask Processor": URMaskProcessor,
|
||||
"UR Keyframe Prep": URKeyframePrep,
|
||||
"UR Soft Composite": URSoftComposite,
|
||||
}
|
||||
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"UR Demo Image": "UR Demo Image",
|
||||
"UR Remove Prompt Builder": "UR Remove Prompt Builder",
|
||||
"UR Region Editor": "UR Region Editor",
|
||||
"UR Boxes To Mask Batch": "UR Boxes To Mask Batch",
|
||||
"UR Regions To Mask Batch": "UR Regions To Mask Batch",
|
||||
"UR Mask Processor": "UR Mask Processor",
|
||||
"UR Keyframe Prep": "UR Keyframe Prep",
|
||||
"UR Soft Composite": "UR Soft Composite",
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
@@ -0,0 +1 @@
|
||||
pytest>=7.8.0
|
||||
@@ -0,0 +1,244 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
MODULE_PATH = Path(__file__).resolve().parents[1] / "nodes.py"
|
||||
SPEC = importlib.util.spec_from_file_location("universal_remove_nodes", MODULE_PATH)
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
def test_extension_entrypoint_exports_nodes_and_web_directory():
|
||||
package_path = MODULE_PATH.parent / "__init__.py"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"ComfyUI_UniversalRemove_test",
|
||||
package_path,
|
||||
submodule_search_locations=[str(MODULE_PATH.parent)],
|
||||
)
|
||||
package = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = package
|
||||
assert spec is not None and spec.loader is not None
|
||||
spec.loader.exec_module(package)
|
||||
|
||||
assert package.WEB_DIRECTORY == "./web"
|
||||
assert "UR Region Editor" in package.NODE_CLASS_MAPPINGS
|
||||
assert "UR Regions To Mask Batch" in package.NODE_CLASS_MAPPINGS
|
||||
|
||||
|
||||
def test_prompt_builder_outputs_expected_strings():
|
||||
node = MODULE.URRemovePromptBuilder()
|
||||
segmentation_prompt, positive_prompt, negative_prompt = node.build(
|
||||
target_prompt="watermark",
|
||||
scene_hint="night city street",
|
||||
preserve_prompt="keep the lighting and camera framing",
|
||||
extra_negative_prompt="text remnants",
|
||||
fill_mode="background_reconstruct",
|
||||
masked_edit_only=True,
|
||||
)
|
||||
|
||||
assert segmentation_prompt == "watermark"
|
||||
assert "Remove watermark" in positive_prompt
|
||||
assert "night city street" in positive_prompt
|
||||
assert "keep the lighting and camera framing" in positive_prompt
|
||||
assert "text remnants" in negative_prompt
|
||||
|
||||
|
||||
def test_demo_image_is_self_contained_and_batched():
|
||||
node = MODULE.URDemoImage()
|
||||
(image,) = node.generate(width=64, height=32, batch_size=2)
|
||||
|
||||
assert image.shape == (2, 32, 64, 3)
|
||||
assert image.min().item() >= 0.0
|
||||
assert image.max().item() <= 1.0
|
||||
|
||||
|
||||
def test_region_editor_normalizes_regions_and_counts_them():
|
||||
node = MODULE.URRegionEditor()
|
||||
regions_json, canvas_width, canvas_height, region_count = node.emit(
|
||||
regions_json='[{"type":"rect","x":1,"y":2,"w":3,"h":4}]',
|
||||
canvas_width=64,
|
||||
canvas_height=32,
|
||||
default_frame_index=5,
|
||||
)
|
||||
|
||||
assert '"frame_index": 5' in regions_json
|
||||
assert canvas_width == 64
|
||||
assert canvas_height == 32
|
||||
assert region_count == 1
|
||||
|
||||
|
||||
def test_boxes_to_mask_batch_repeats_mask_to_batch():
|
||||
node = MODULE.URBoxesToMaskBatch()
|
||||
images = torch.zeros((2, 4, 4, 3), dtype=torch.float32)
|
||||
|
||||
mask, selected_frame, preview = node.build_masks(
|
||||
images=images,
|
||||
boxes_json="[[1, 1, 3, 3]]",
|
||||
frame_index=0,
|
||||
coordinate_format="xyxy",
|
||||
broadcast_mode="repeat_to_batch",
|
||||
normalized=False,
|
||||
preview_color="#FF0000",
|
||||
preview_opacity=0.5,
|
||||
)
|
||||
|
||||
expected = torch.tensor(
|
||||
[
|
||||
[0, 0, 0, 0],
|
||||
[0, 1, 1, 0],
|
||||
[0, 1, 1, 0],
|
||||
[0, 0, 0, 0],
|
||||
],
|
||||
dtype=torch.float32,
|
||||
)
|
||||
assert mask.shape == (2, 4, 4)
|
||||
assert torch.equal(mask[0], expected)
|
||||
assert torch.equal(mask[1], expected)
|
||||
assert selected_frame.shape == (1, 4, 4, 3)
|
||||
assert preview.shape == (1, 4, 4, 3)
|
||||
|
||||
|
||||
def test_boxes_to_mask_batch_handles_reversed_out_of_bounds_box():
|
||||
node = MODULE.URBoxesToMaskBatch()
|
||||
images = torch.zeros((1, 4, 4, 3), dtype=torch.float32)
|
||||
|
||||
mask, _, _ = node.build_masks(
|
||||
images=images,
|
||||
boxes_json="[[5, 5, -1, -1]]",
|
||||
frame_index=0,
|
||||
coordinate_format="xyxy",
|
||||
broadcast_mode="single_frame_only",
|
||||
normalized=False,
|
||||
preview_color="#FF0000",
|
||||
preview_opacity=0.5,
|
||||
)
|
||||
|
||||
assert mask.shape == (1, 4, 4)
|
||||
assert mask.sum().item() == 16.0
|
||||
|
||||
|
||||
def test_regions_to_mask_batch_supports_rect_circle_and_polygon():
|
||||
node = MODULE.URRegionsToMaskBatch()
|
||||
images = torch.zeros((1, 10, 10, 3), dtype=torch.float32)
|
||||
regions_json = """
|
||||
[
|
||||
{"type": "rect", "frame_index": 0, "x": 1, "y": 1, "w": 2, "h": 2},
|
||||
{"type": "circle", "frame_index": 0, "cx": 7, "cy": 7, "r": 1.5},
|
||||
{"type": "polygon", "frame_index": 0, "points": [[5, 1], [8, 1], [8, 3], [5, 3]]}
|
||||
]
|
||||
"""
|
||||
|
||||
mask, selected_frame, preview = node.build_masks(
|
||||
images=images,
|
||||
regions_json=regions_json,
|
||||
default_frame_index=0,
|
||||
broadcast_mode="single_frame_only",
|
||||
normalized=False,
|
||||
preview_frame=0,
|
||||
preview_color="#FF0000",
|
||||
preview_opacity=0.5,
|
||||
)
|
||||
|
||||
assert mask.shape == (1, 10, 10)
|
||||
assert mask[0, 1:3, 1:3].sum().item() == 4.0
|
||||
assert mask[0, 7, 7].item() == 1.0
|
||||
assert mask[0, 1:3, 5:8].sum().item() > 0.0
|
||||
assert selected_frame.shape == (1, 10, 10, 3)
|
||||
assert preview.shape == (1, 10, 10, 3)
|
||||
|
||||
|
||||
def test_regions_to_mask_batch_holds_between_keyframes():
|
||||
node = MODULE.URRegionsToMaskBatch()
|
||||
images = torch.zeros((4, 6, 6, 3), dtype=torch.float32)
|
||||
regions_json = """
|
||||
[
|
||||
{"type": "rect", "frame_index": 0, "x": 0, "y": 0, "w": 2, "h": 2},
|
||||
{"type": "rect", "frame_index": 2, "x": 4, "y": 4, "w": 2, "h": 2}
|
||||
]
|
||||
"""
|
||||
|
||||
mask, _, _ = node.build_masks(
|
||||
images=images,
|
||||
regions_json=regions_json,
|
||||
default_frame_index=0,
|
||||
broadcast_mode="hold_until_next_keyframe",
|
||||
normalized=False,
|
||||
preview_frame=0,
|
||||
preview_color="#FF0000",
|
||||
preview_opacity=0.5,
|
||||
)
|
||||
|
||||
assert mask.shape == (4, 6, 6)
|
||||
assert mask[0, 0:2, 0:2].sum().item() == 4.0
|
||||
assert mask[1, 0:2, 0:2].sum().item() == 4.0
|
||||
assert mask[2, 4:6, 4:6].sum().item() == 4.0
|
||||
assert mask[3, 4:6, 4:6].sum().item() == 4.0
|
||||
assert mask[1, 4:6, 4:6].sum().item() == 0.0
|
||||
|
||||
|
||||
def test_mask_processor_can_expand_mask_without_image():
|
||||
node = MODULE.URMaskProcessor()
|
||||
mask = torch.zeros((1, 5, 5), dtype=torch.float32)
|
||||
mask[:, 2, 2] = 1.0
|
||||
|
||||
processed_mask, mask_image, preview = node.process(
|
||||
mask=mask,
|
||||
expand_pixels=1,
|
||||
blur_sigma=0.0,
|
||||
blur_kernel=0,
|
||||
preview_color="#00FF00",
|
||||
preview_opacity=0.5,
|
||||
image=None,
|
||||
)
|
||||
|
||||
assert processed_mask.sum().item() == 9.0
|
||||
assert mask_image.shape == (1, 5, 5, 3)
|
||||
assert preview.shape == (1, 5, 5, 3)
|
||||
|
||||
|
||||
def test_keyframe_prep_fills_masked_region_and_pads():
|
||||
node = MODULE.URKeyframePrep()
|
||||
images = torch.zeros((1, 3, 5, 3), dtype=torch.float32)
|
||||
mask = torch.zeros((1, 3, 5), dtype=torch.float32)
|
||||
mask[:, 1, 2] = 1.0
|
||||
|
||||
original_frame, prepared_frame, prepared_mask, filled_frame, prepared_width, prepared_height, frame_count = node.prepare(
|
||||
images=images,
|
||||
mask=mask,
|
||||
frame_index=0,
|
||||
fill_color="#00FF00",
|
||||
resize_longest_side=0,
|
||||
pad_to_multiple=4,
|
||||
)
|
||||
|
||||
assert original_frame.shape == (1, 3, 5, 3)
|
||||
assert prepared_frame.shape == (1, 4, 8, 3)
|
||||
assert prepared_mask.shape == (1, 4, 8)
|
||||
assert prepared_width == 8
|
||||
assert prepared_height == 4
|
||||
assert frame_count == 1
|
||||
assert torch.allclose(filled_frame[0, 1, 2], torch.tensor([0.0, 1.0, 0.0]))
|
||||
|
||||
|
||||
def test_soft_composite_uses_mask_for_direct_blend_when_no_blur():
|
||||
node = MODULE.URSoftComposite()
|
||||
original = torch.zeros((1, 4, 4, 3), dtype=torch.float32)
|
||||
inpaint = torch.ones((1, 4, 4, 3), dtype=torch.float32)
|
||||
mask = torch.zeros((1, 4, 4), dtype=torch.float32)
|
||||
mask[:, 1:3, 1:3] = 1.0
|
||||
|
||||
composited, soft_mask = node.composite(
|
||||
inpaint=inpaint,
|
||||
original=original,
|
||||
mask=mask,
|
||||
kernel=1,
|
||||
sigma=0.0,
|
||||
)
|
||||
|
||||
assert torch.equal(soft_mask, mask)
|
||||
assert torch.allclose(composited[0, 0, 0], torch.tensor([0.0, 0.0, 0.0]))
|
||||
assert torch.allclose(composited[0, 1, 1], torch.tensor([1.0, 1.0, 1.0]))
|
||||
@@ -0,0 +1,535 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
function chainCallback(object, property, callback) {
|
||||
const original = object[property];
|
||||
if (original) {
|
||||
object[property] = function (...args) {
|
||||
const result = original.apply(this, args);
|
||||
callback.apply(this, args);
|
||||
return result;
|
||||
};
|
||||
} else {
|
||||
object[property] = callback;
|
||||
}
|
||||
}
|
||||
|
||||
function findWidget(node, name) {
|
||||
return node.widgets?.find((widget) => widget.name === name);
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function parseRegions(value) {
|
||||
try {
|
||||
const parsed = JSON.parse(value || "[]");
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
if (parsed && Array.isArray(parsed.regions)) return parsed.regions;
|
||||
if (parsed && typeof parsed === "object") return [parsed];
|
||||
} catch (error) {
|
||||
console.warn("UR Region Editor: failed to parse regions_json", error);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeRect(region) {
|
||||
const x = Number(region.x ?? region.x1 ?? 0);
|
||||
const y = Number(region.y ?? region.y1 ?? 0);
|
||||
if (region.w != null || region.h != null) {
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
w: Number(region.w ?? 0),
|
||||
h: Number(region.h ?? 0),
|
||||
};
|
||||
}
|
||||
const x2 = Number(region.x2 ?? region.right ?? x);
|
||||
const y2 = Number(region.y2 ?? region.bottom ?? y);
|
||||
return {
|
||||
x: Math.min(x, x2),
|
||||
y: Math.min(y, y2),
|
||||
w: Math.abs(x2 - x),
|
||||
h: Math.abs(y2 - y),
|
||||
};
|
||||
}
|
||||
|
||||
function createButton(label, title) {
|
||||
const button = document.createElement("button");
|
||||
button.textContent = label;
|
||||
button.title = title || label;
|
||||
button.style.cssText = "height:24px;padding:0 8px;border:1px solid #555;background:#2b2b2b;color:#ddd;border-radius:4px;cursor:pointer;font-size:12px;";
|
||||
button.addEventListener("mouseenter", () => {
|
||||
button.style.background = "#3a3a3a";
|
||||
});
|
||||
button.addEventListener("mouseleave", () => {
|
||||
button.style.background = button.dataset.active === "true" ? "#4b5f7a" : "#2b2b2b";
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
function createNumberInput(value, min, title) {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.value = String(value);
|
||||
input.min = String(min ?? 0);
|
||||
input.title = title || "";
|
||||
input.style.cssText = "height:22px;width:76px;background:#111;color:#ddd;border:1px solid #555;border-radius:4px;padding:0 4px;font-size:12px;";
|
||||
return input;
|
||||
}
|
||||
|
||||
class RegionEditorWidget {
|
||||
constructor(node) {
|
||||
this.node = node;
|
||||
this.regionsWidget = findWidget(node, "regions_json");
|
||||
this.widthWidget = findWidget(node, "canvas_width");
|
||||
this.heightWidget = findWidget(node, "canvas_height");
|
||||
this.defaultFrameWidget = findWidget(node, "default_frame_index");
|
||||
|
||||
this.regions = parseRegions(this.regionsWidget?.value);
|
||||
this.mode = "rect";
|
||||
this.activeFrame = Number(this.defaultFrameWidget?.value ?? 0);
|
||||
this.selectedIndex = -1;
|
||||
this.dragStart = null;
|
||||
this.currentDraft = null;
|
||||
this.polygonDraft = [];
|
||||
this.background = null;
|
||||
this.video = null;
|
||||
this.videoFps = 24;
|
||||
|
||||
this.root = document.createElement("div");
|
||||
this.root.style.cssText = "display:flex;flex-direction:column;gap:6px;width:100%;height:100%;box-sizing:border-box;background:#121212;border:1px solid #303030;border-radius:6px;padding:6px;overflow:hidden;";
|
||||
|
||||
this.toolbar = document.createElement("div");
|
||||
this.toolbar.style.cssText = "display:flex;align-items:center;gap:6px;flex-wrap:wrap;";
|
||||
|
||||
this.rectBtn = createButton("Rect", "Draw rectangle by dragging");
|
||||
this.circleBtn = createButton("Circle", "Draw circle by dragging");
|
||||
this.polyBtn = createButton("Poly", "Click points, then Finish Poly");
|
||||
this.finishPolyBtn = createButton("Finish Poly", "Finish current polygon");
|
||||
this.undoPointBtn = createButton("Undo Point", "Remove last polygon point");
|
||||
this.deleteBtn = createButton("Delete", "Delete selected region");
|
||||
this.clearBtn = createButton("Clear", "Clear all regions");
|
||||
this.loadBgBtn = createButton("Load BG", "Load local image/video as editor background only");
|
||||
|
||||
this.frameInput = createNumberInput(this.activeFrame, 0, "Current keyframe index");
|
||||
this.frameLabel = document.createElement("span");
|
||||
this.frameLabel.textContent = "Frame";
|
||||
this.frameLabel.style.cssText = "color:#aaa;font-size:12px;";
|
||||
|
||||
this.fileInput = document.createElement("input");
|
||||
this.fileInput.type = "file";
|
||||
this.fileInput.accept = "image/*,video/*";
|
||||
this.fileInput.style.display = "none";
|
||||
|
||||
this.videoControls = document.createElement("div");
|
||||
this.videoControls.style.cssText = "display:none;align-items:center;gap:6px;width:100%;";
|
||||
this.playBtn = createButton("Play", "Play/pause background video");
|
||||
this.videoSlider = document.createElement("input");
|
||||
this.videoSlider.type = "range";
|
||||
this.videoSlider.min = "0";
|
||||
this.videoSlider.max = "0";
|
||||
this.videoSlider.step = "0.001";
|
||||
this.videoSlider.value = "0";
|
||||
this.videoSlider.style.cssText = "flex:1;";
|
||||
this.fpsInput = createNumberInput(this.videoFps, 1, "FPS used to convert video time to frame index");
|
||||
this.useVideoFrameBtn = createButton("Use Time", "Set keyframe from current video time");
|
||||
|
||||
this.canvas = document.createElement("canvas");
|
||||
this.canvas.width = Number(this.widthWidget?.value ?? 768);
|
||||
this.canvas.height = Number(this.heightWidget?.value ?? 512);
|
||||
this.canvas.style.cssText = "width:100%;height:auto;max-height:520px;object-fit:contain;background:#181818;border:1px solid #333;border-radius:4px;cursor:crosshair;";
|
||||
this.ctx = this.canvas.getContext("2d");
|
||||
|
||||
this.status = document.createElement("div");
|
||||
this.status.style.cssText = "color:#aaa;font-size:12px;white-space:pre-wrap;line-height:1.3;";
|
||||
|
||||
this.toolbar.append(
|
||||
this.rectBtn,
|
||||
this.circleBtn,
|
||||
this.polyBtn,
|
||||
this.finishPolyBtn,
|
||||
this.undoPointBtn,
|
||||
this.deleteBtn,
|
||||
this.clearBtn,
|
||||
this.loadBgBtn,
|
||||
this.frameLabel,
|
||||
this.frameInput,
|
||||
this.fileInput,
|
||||
);
|
||||
this.videoControls.append(this.playBtn, this.videoSlider, document.createTextNode("FPS"), this.fpsInput, this.useVideoFrameBtn);
|
||||
this.root.append(this.toolbar, this.videoControls, this.canvas, this.status);
|
||||
|
||||
this.bindEvents();
|
||||
this.setMode("rect");
|
||||
this.syncWidgets(false);
|
||||
this.render();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.rectBtn.onclick = () => this.setMode("rect");
|
||||
this.circleBtn.onclick = () => this.setMode("circle");
|
||||
this.polyBtn.onclick = () => this.setMode("polygon");
|
||||
this.finishPolyBtn.onclick = () => this.finishPolygon();
|
||||
this.undoPointBtn.onclick = () => {
|
||||
this.polygonDraft.pop();
|
||||
this.render();
|
||||
};
|
||||
this.deleteBtn.onclick = () => this.deleteSelected();
|
||||
this.clearBtn.onclick = () => {
|
||||
this.regions = [];
|
||||
this.selectedIndex = -1;
|
||||
this.polygonDraft = [];
|
||||
this.syncWidgets(true);
|
||||
this.render();
|
||||
};
|
||||
this.loadBgBtn.onclick = () => this.fileInput.click();
|
||||
|
||||
this.frameInput.addEventListener("change", () => {
|
||||
this.activeFrame = Math.max(0, Number(this.frameInput.value || 0));
|
||||
if (this.defaultFrameWidget) this.defaultFrameWidget.value = this.activeFrame;
|
||||
this.render();
|
||||
});
|
||||
this.fpsInput.addEventListener("change", () => {
|
||||
this.videoFps = Math.max(1, Number(this.fpsInput.value || 24));
|
||||
});
|
||||
|
||||
this.fileInput.addEventListener("change", () => this.loadBackgroundFile());
|
||||
this.playBtn.onclick = () => this.toggleVideo();
|
||||
this.videoSlider.addEventListener("input", () => {
|
||||
if (!this.video) return;
|
||||
this.video.currentTime = Number(this.videoSlider.value || 0);
|
||||
this.drawVideoFrame();
|
||||
});
|
||||
this.useVideoFrameBtn.onclick = () => {
|
||||
if (!this.video) return;
|
||||
this.activeFrame = Math.max(0, Math.round(this.video.currentTime * this.videoFps));
|
||||
this.frameInput.value = String(this.activeFrame);
|
||||
if (this.defaultFrameWidget) this.defaultFrameWidget.value = this.activeFrame;
|
||||
this.render();
|
||||
};
|
||||
|
||||
this.canvas.addEventListener("mousedown", (event) => this.onMouseDown(event));
|
||||
this.canvas.addEventListener("mousemove", (event) => this.onMouseMove(event));
|
||||
this.canvas.addEventListener("mouseup", (event) => this.onMouseUp(event));
|
||||
this.canvas.addEventListener("dblclick", () => this.finishPolygon());
|
||||
this.canvas.addEventListener("contextmenu", (event) => {
|
||||
event.preventDefault();
|
||||
const point = this.toCanvasPoint(event);
|
||||
const index = this.hitTest(point.x, point.y);
|
||||
if (index >= 0) {
|
||||
this.selectedIndex = index;
|
||||
this.deleteSelected();
|
||||
}
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (!this.root.isConnected) return;
|
||||
if (event.key === "Delete" || event.key === "Backspace") this.deleteSelected();
|
||||
if (event.key === "Enter" && this.mode === "polygon") this.finishPolygon();
|
||||
if (event.key === "Escape") {
|
||||
this.currentDraft = null;
|
||||
this.polygonDraft = [];
|
||||
this.render();
|
||||
}
|
||||
});
|
||||
|
||||
if (this.regionsWidget) {
|
||||
const previous = this.regionsWidget.callback;
|
||||
this.regionsWidget.callback = (...args) => {
|
||||
if (previous) previous.apply(this.regionsWidget, args);
|
||||
this.regions = parseRegions(this.regionsWidget.value);
|
||||
this.render();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
setMode(mode) {
|
||||
this.mode = mode;
|
||||
for (const [button, name] of [[this.rectBtn, "rect"], [this.circleBtn, "circle"], [this.polyBtn, "polygon"]]) {
|
||||
button.dataset.active = name === mode ? "true" : "false";
|
||||
button.style.background = name === mode ? "#4b5f7a" : "#2b2b2b";
|
||||
}
|
||||
this.currentDraft = null;
|
||||
this.render();
|
||||
}
|
||||
|
||||
syncWidgets(markDirty) {
|
||||
const text = JSON.stringify(this.regions);
|
||||
if (this.regionsWidget) this.regionsWidget.value = text;
|
||||
if (this.widthWidget) this.widthWidget.value = this.canvas.width;
|
||||
if (this.heightWidget) this.heightWidget.value = this.canvas.height;
|
||||
if (this.defaultFrameWidget) this.defaultFrameWidget.value = this.activeFrame;
|
||||
if (markDirty) app.graph?.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
loadBackgroundFile() {
|
||||
const file = this.fileInput.files?.[0];
|
||||
if (!file) return;
|
||||
const url = URL.createObjectURL(file);
|
||||
this.background = null;
|
||||
this.video = null;
|
||||
this.videoControls.style.display = "none";
|
||||
if (file.type.startsWith("video/")) {
|
||||
const video = document.createElement("video");
|
||||
video.src = url;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.addEventListener("loadedmetadata", () => {
|
||||
this.video = video;
|
||||
this.canvas.width = video.videoWidth || this.canvas.width;
|
||||
this.canvas.height = video.videoHeight || this.canvas.height;
|
||||
this.videoSlider.max = String(video.duration || 0);
|
||||
this.videoControls.style.display = "flex";
|
||||
this.drawVideoFrame();
|
||||
this.syncWidgets(true);
|
||||
});
|
||||
video.addEventListener("timeupdate", () => {
|
||||
this.videoSlider.value = String(video.currentTime);
|
||||
this.drawVideoFrame();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
this.background = image;
|
||||
this.canvas.width = image.width;
|
||||
this.canvas.height = image.height;
|
||||
this.syncWidgets(true);
|
||||
this.render();
|
||||
};
|
||||
image.src = url;
|
||||
}
|
||||
|
||||
toggleVideo() {
|
||||
if (!this.video) return;
|
||||
if (this.video.paused) {
|
||||
this.video.play();
|
||||
this.playBtn.textContent = "Pause";
|
||||
} else {
|
||||
this.video.pause();
|
||||
this.playBtn.textContent = "Play";
|
||||
}
|
||||
}
|
||||
|
||||
drawVideoFrame() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
toCanvasPoint(event) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: clamp((event.clientX - rect.left) * (this.canvas.width / rect.width), 0, this.canvas.width),
|
||||
y: clamp((event.clientY - rect.top) * (this.canvas.height / rect.height), 0, this.canvas.height),
|
||||
};
|
||||
}
|
||||
|
||||
onMouseDown(event) {
|
||||
event.preventDefault();
|
||||
const point = this.toCanvasPoint(event);
|
||||
const hit = this.hitTest(point.x, point.y);
|
||||
if (hit >= 0 && event.shiftKey) {
|
||||
this.selectedIndex = hit;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
if (this.mode === "polygon") {
|
||||
this.polygonDraft.push(point);
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
this.dragStart = point;
|
||||
this.currentDraft = { type: this.mode, frame_index: this.activeFrame, start: point, end: point };
|
||||
}
|
||||
|
||||
onMouseMove(event) {
|
||||
if (!this.currentDraft) return;
|
||||
this.currentDraft.end = this.toCanvasPoint(event);
|
||||
this.render();
|
||||
}
|
||||
|
||||
onMouseUp(event) {
|
||||
if (!this.currentDraft) return;
|
||||
this.currentDraft.end = this.toCanvasPoint(event);
|
||||
const region = this.draftToRegion(this.currentDraft);
|
||||
this.currentDraft = null;
|
||||
if (region) {
|
||||
this.regions.push(region);
|
||||
this.selectedIndex = this.regions.length - 1;
|
||||
this.syncWidgets(true);
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
|
||||
draftToRegion(draft) {
|
||||
const x1 = draft.start.x;
|
||||
const y1 = draft.start.y;
|
||||
const x2 = draft.end.x;
|
||||
const y2 = draft.end.y;
|
||||
if (draft.type === "rect") {
|
||||
const x = Math.min(x1, x2);
|
||||
const y = Math.min(y1, y2);
|
||||
const w = Math.abs(x2 - x1);
|
||||
const h = Math.abs(y2 - y1);
|
||||
if (w < 3 || h < 3) return null;
|
||||
return { type: "rect", frame_index: this.activeFrame, x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) };
|
||||
}
|
||||
if (draft.type === "circle") {
|
||||
const r = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
|
||||
if (r < 3) return null;
|
||||
return { type: "circle", frame_index: this.activeFrame, cx: Math.round(x1), cy: Math.round(y1), r: Math.round(r) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
finishPolygon() {
|
||||
if (this.polygonDraft.length < 3) return;
|
||||
this.regions.push({
|
||||
type: "polygon",
|
||||
frame_index: this.activeFrame,
|
||||
points: this.polygonDraft.map((point) => [Math.round(point.x), Math.round(point.y)]),
|
||||
});
|
||||
this.selectedIndex = this.regions.length - 1;
|
||||
this.polygonDraft = [];
|
||||
this.syncWidgets(true);
|
||||
this.render();
|
||||
}
|
||||
|
||||
deleteSelected() {
|
||||
if (this.selectedIndex < 0 || this.selectedIndex >= this.regions.length) return;
|
||||
this.regions.splice(this.selectedIndex, 1);
|
||||
this.selectedIndex = -1;
|
||||
this.syncWidgets(true);
|
||||
this.render();
|
||||
}
|
||||
|
||||
hitTest(x, y) {
|
||||
for (let i = this.regions.length - 1; i >= 0; i--) {
|
||||
const region = this.regions[i];
|
||||
if (Number(region.frame_index ?? 0) !== this.activeFrame) continue;
|
||||
const type = String(region.type || "rect").toLowerCase();
|
||||
if (type === "rect") {
|
||||
const rect = normalizeRect(region);
|
||||
if (x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h) return i;
|
||||
} else if (type === "circle") {
|
||||
const dx = x - Number(region.cx ?? region.x ?? 0);
|
||||
const dy = y - Number(region.cy ?? region.y ?? 0);
|
||||
if (dx * dx + dy * dy <= Number(region.r ?? region.radius ?? 0) ** 2) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
render() {
|
||||
const ctx = this.ctx;
|
||||
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
ctx.fillStyle = "#181818";
|
||||
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
if (this.video) ctx.drawImage(this.video, 0, 0, this.canvas.width, this.canvas.height);
|
||||
else if (this.background) ctx.drawImage(this.background, 0, 0, this.canvas.width, this.canvas.height);
|
||||
else this.drawGrid();
|
||||
|
||||
this.regions.forEach((region, index) => this.drawRegion(region, index === this.selectedIndex));
|
||||
if (this.currentDraft) this.drawDraft(this.currentDraft);
|
||||
if (this.polygonDraft.length) this.drawPolygonDraft();
|
||||
this.status.textContent = `Mode: ${this.mode} | Frame: ${this.activeFrame} | Regions: ${this.regions.length}\nLoad BG is editor-only; regions_json is portable and saved in the workflow.`;
|
||||
}
|
||||
|
||||
drawGrid() {
|
||||
const ctx = this.ctx;
|
||||
ctx.strokeStyle = "#2a2a2a";
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = 0; x <= this.canvas.width; x += 64) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, this.canvas.height);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = 0; y <= this.canvas.height; y += 64) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(this.canvas.width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.fillStyle = "#777";
|
||||
ctx.font = "16px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("Load image/video background, then draw regions", this.canvas.width / 2, this.canvas.height / 2);
|
||||
}
|
||||
|
||||
drawRegion(region, selected) {
|
||||
const ctx = this.ctx;
|
||||
const type = String(region.type || "rect").toLowerCase();
|
||||
ctx.save();
|
||||
ctx.strokeStyle = selected ? "#ffcc00" : "#35c759";
|
||||
ctx.fillStyle = selected ? "rgba(255,204,0,0.20)" : "rgba(53,199,89,0.20)";
|
||||
ctx.lineWidth = selected ? 3 : 2;
|
||||
if (type === "rect") {
|
||||
const rect = normalizeRect(region);
|
||||
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
||||
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
|
||||
} else if (type === "circle") {
|
||||
ctx.beginPath();
|
||||
ctx.arc(Number(region.cx ?? region.x ?? 0), Number(region.cy ?? region.y ?? 0), Number(region.r ?? region.radius ?? 0), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
} else if (type === "polygon") {
|
||||
this.drawPolygonPath(region.points || []);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawPolygonPath(points) {
|
||||
const ctx = this.ctx;
|
||||
ctx.beginPath();
|
||||
points.forEach((point, index) => {
|
||||
const x = Array.isArray(point) ? point[0] : point.x;
|
||||
const y = Array.isArray(point) ? point[1] : point.y;
|
||||
if (index === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
drawDraft(draft) {
|
||||
const region = this.draftToRegion(draft);
|
||||
if (!region) return;
|
||||
this.drawRegion(region, true);
|
||||
}
|
||||
|
||||
drawPolygonDraft() {
|
||||
const ctx = this.ctx;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "#ffcc00";
|
||||
ctx.fillStyle = "#ffcc00";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
this.polygonDraft.forEach((point, index) => {
|
||||
if (index === 0) ctx.moveTo(point.x, point.y);
|
||||
else ctx.lineTo(point.x, point.y);
|
||||
ctx.fillRect(point.x - 3, point.y - 3, 6, 6);
|
||||
});
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.UniversalRemove.RegionEditor",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData?.name !== "UR Region Editor") return;
|
||||
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
||||
this.serialize_widgets = true;
|
||||
const editor = new RegionEditorWidget(this);
|
||||
this.urRegionEditor = editor;
|
||||
const widget = this.addDOMWidget("region_editor", "region_editor", editor.root);
|
||||
widget.computeSize = (width) => [width, Math.max(420, this.size?.[1] ? this.size[1] - 160 : 420)];
|
||||
this.setSize([Math.max(this.size?.[0] || 680, 680), Math.max(this.size?.[1] || 680, 680)]);
|
||||
chainCallback(this, "onResize", function () {
|
||||
editor.render();
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user