This commit is contained in:
yige1127
2026-02-08 20:41:02 +08:00
parent d89a64333e
commit 802e05ebac
11 changed files with 585 additions and 21 deletions
+5
View File
@@ -21,6 +21,9 @@ git clone https://github.com/1hew/ComfyUI-1hewNodes
## 📜 更新日志
**v3.3.0**
- feat(conversion): 添加 `Mask to SAM3 Box`、`Text to Any` 节点
**v3.2.6**
- refactor(condition): 优化 `Text Encode QwenImageEdit` 节点
@@ -442,9 +445,11 @@ git clone https://github.com/1hew/ComfyUI-1hewNodes
| Image List to Batch | 将图像列表转换为批量图像,自动进行尺寸标准化 |
| Mask Batch to List | 将批量遮罩转换为遮罩列表,用于单独处理 |
| Mask List to Batch | 将遮罩列表转换为批量遮罩,自动进行尺寸标准化 |
| Mask to SAM3 Box | 将遮罩转换为 SAM3 框提示(归一化 cx,cy,bw,bh),支持正/负与合并/分离模式 |
| String Coordinate to BBoxes | 将字符串格式坐标转换为BBOXES格式,增强格式支持并改进SAM2兼容性 |
| String Coordinate to BBox Mask | 将字符串格式坐标转换为BBoxMask格式,支持图像尺寸获取和灵活的输出模式 |
| Text List to String | 文本列表合并,逐项应用前后缀并按分隔符拼接,支持转义与复合分隔符 |
| Text to Any | 将文本输出为通配类型,便于连接到更多输入端 |
### 🧠 逻辑节点
| 节点名称 | 功能描述 |
+5
View File
@@ -21,6 +21,9 @@ git clone https://github.com/1hew/ComfyUI-1hewNodes
## 📜 Changelog
**v3.3.0**
- feat(conversion): Add `Mask to SAM3 Box` and `Text to Any` nodes
**v3.2.6**
- refactor(condition): Optimize `Text Encode QwenImageEdit` node
@@ -442,9 +445,11 @@ git clone https://github.com/1hew/ComfyUI-1hewNodes
| Image List to Batch | Convert image lists to batch images with automatic size normalization |
| Mask Batch to List | Convert batch masks to mask lists for individual processing |
| Mask List to Batch | Convert mask lists to batch masks with automatic size normalization |
| Mask to SAM3 Box | Convert masks to SAM3 box prompts (normalized cx, cy, bw, bh), supporting positive/negative and merge/separate modes |
| String Coordinate to BBoxes | Convert string format coordinates to BBOXES format with enhanced format support and improved SAM2 compatibility |
| String Coordinate to BBox Mask | Convert string format coordinates to BBoxMask format with image dimension support and flexible output modes |
| Text List to String | Merge a text list by applying per-item prefix/suffix and joining with a separator; supports escape sequences and composite separators |
| Text to Any | Output text as a wildcard payload for flexible connections (e.g., COMBO inputs) |
### 🧠 Logic Nodes
| Node Name | Description |
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:968e531a2abe373d5c52c6c3a10beba1d3fc4fabce2c27e515aa6b97dd7be71e
size 117824
oid sha256:12effe04a03c1c0b4a2a1c0a9faedbb40189fedd8dec8b2996d6d75b3786633d
size 133970
+146
View File
@@ -0,0 +1,146 @@
import numpy as np
import torch
from comfy_api.latest import io
from skimage.measure import label, regionprops
class MaskToSam3Box(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="1hew_MaskToSam3Box",
display_name="Mask to SAM3 Box",
category="1hewNodes/conversion",
inputs=[
io.Mask.Input("mask"),
io.Combo.Input(
"condition",
options=["positive", "negative"],
default="positive",
),
io.Combo.Input(
"output_mode",
options=["merge", "separate"],
default="merge",
),
],
outputs=[
io.Custom("SAM3_BOXES_PROMPT").Output(
display_name="sam3_box"
),
],
)
@classmethod
def execute(
cls,
mask: torch.Tensor,
condition: str,
output_mode: str,
) -> io.NodeOutput:
if mask.dim() == 2:
mask = mask.unsqueeze(0)
batch, height, width = mask.shape[:3]
prompts: list[dict[str, list]] = []
is_positive = condition == "positive"
for b in range(batch):
boxes_xyxy = cls._boxes_from_mask(
mask[b],
width=width,
height=height,
output_mode=output_mode,
)
formatted = cls._to_collector_boxes(
boxes_xyxy,
width=width,
height=height,
)
prompts.append(
{
"boxes": formatted,
"labels": [is_positive] * len(formatted),
}
)
if batch == 1:
return io.NodeOutput(prompts[0])
return io.NodeOutput(prompts)
@classmethod
def _boxes_from_mask(
cls,
mask_2d: torch.Tensor,
width: int,
height: int,
output_mode: str,
) -> list[list[int]]:
if mask_2d.numel() == 0:
return []
mask_np = (mask_2d.detach().cpu().numpy() > 0.5).astype(np.uint8)
if not np.any(mask_np):
return []
if output_mode == "merge":
ys, xs = np.where(mask_np > 0)
x_min = int(xs.min())
x_max = int(xs.max()) + 1
y_min = int(ys.min())
y_max = int(ys.max()) + 1
box = cls._clamp_box(
[x_min, y_min, x_max, y_max],
width=width,
height=height,
)
return [box] if box else []
labeled = label(mask_np > 0)
regions = regionprops(labeled)
boxes: list[list[int]] = []
for region in regions:
min_row, min_col, max_row, max_col = region.bbox
x_min = int(min_col)
x_max = int(max_col)
y_min = int(min_row)
y_max = int(max_row)
box = cls._clamp_box(
[x_min, y_min, x_max, y_max],
width=width,
height=height,
)
if box:
boxes.append(box)
return boxes
@staticmethod
def _clamp_box(
box_xyxy: list[int],
width: int,
height: int,
) -> list[int] | None:
x_min, y_min, x_max, y_max = box_xyxy
x_min = max(0, x_min)
y_min = max(0, y_min)
x_max = min(width, x_max)
y_max = min(height, y_max)
if x_max <= x_min or y_max <= y_min:
return None
return [int(x_min), int(y_min), int(x_max), int(y_max)]
@staticmethod
def _to_collector_boxes(
boxes_xyxy: list[list[int]],
width: int,
height: int,
) -> list[list[float]]:
w = float(width) if width else 1.0
h = float(height) if height else 1.0
formatted: list[list[float]] = []
for x1, y1, x2, y2 in boxes_xyxy:
cx = (float(x1) + float(x2)) / 2.0 / w
cy = (float(y1) + float(y2)) / 2.0 / h
bw = (float(x2) - float(x1)) / w
bh = (float(y2) - float(y1)) / h
formatted.append([cx, cy, bw, bh])
return formatted
+28
View File
@@ -0,0 +1,28 @@
from comfy_api.latest import io
class TextToAny(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="1hew_TextToAny",
display_name="Text to Any",
category="1hewNodes/conversion",
inputs=[
io.String.Input("text", default="", multiline=True),
],
outputs=[
io.Custom("*").Output(display_name="any"),
],
)
@classmethod
def validate_inputs(cls, **kwargs):
return True
@classmethod
async def execute(cls, text: str) -> io.NodeOutput:
if isinstance(text, (list, tuple)):
text = text[0] if len(text) > 0 else ""
text = "" if text is None else str(text)
return io.NodeOutput(text)
+274 -18
View File
@@ -94,31 +94,266 @@ class TextCustomExtract(io.ComfyNode):
@classmethod
def _parse_json_data(cls, json_data):
text = cls._clean_input_text(json_data) if isinstance(json_data, str) else str(json_data)
text = (
cls._clean_input_text(json_data)
if isinstance(json_data, str)
else str(json_data)
)
if not text:
return {}
try:
return json.loads(text)
except json.JSONDecodeError:
parsed = cls._try_literal_eval(text)
if parsed is not None:
return parsed
parsed = cls._try_json_loads(text.replace("'", '"'))
if parsed is not None:
return parsed
parsed = cls._try_loose_parse(text)
if parsed is not None:
return parsed
pattern = r'"([^"]+)"\s*:\s*"([^"]+)"'
matches = re.findall(pattern, text)
if matches:
return {key: value for key, value in matches}
return {}
@classmethod
def _try_json_loads(cls, text: str):
try:
return json.loads(text)
except json.JSONDecodeError:
return None
@classmethod
def _try_literal_eval(cls, text: str):
try:
return ast.literal_eval(text)
except (ValueError, SyntaxError):
return None
@classmethod
def _try_loose_parse(cls, text: str):
stripped = text.strip()
if not stripped:
return None
start_idx = None
start_char = None
for idx, ch in enumerate(stripped):
if ch in "[{":
start_idx = idx
start_char = ch
break
if start_idx is None or start_char is None:
return None
end_char = "}" if start_char == "{" else "]"
end_idx = stripped.rfind(end_char)
if end_idx == -1 or end_idx <= start_idx:
return None
container = stripped[start_idx : end_idx + 1]
try:
if start_char == "{":
parsed, _ = cls._loose_parse_dict(container, 0)
return parsed
parsed, _ = cls._loose_parse_list(container, 0)
return parsed
except Exception:
return None
@classmethod
def _loose_parse_dict(cls, text: str, start: int):
if start >= len(text) or text[start] != "{":
raise ValueError("Expected '{'")
idx = start + 1
result = {}
while idx < len(text):
idx = cls._skip_ws(text, idx)
if idx >= len(text):
break
if text[idx] == "}":
return result, idx + 1
if text[idx] == ",":
idx += 1
continue
key, idx = cls._read_key(text, idx)
idx = cls._skip_ws(text, idx)
if idx < len(text) and text[idx] == ":":
idx += 1
idx = cls._skip_ws(text, idx)
value, idx = cls._loose_parse_value(text, idx)
if key:
result[key] = value
idx = cls._skip_ws(text, idx)
if idx < len(text) and text[idx] == ",":
idx += 1
return result, idx
@classmethod
def _loose_parse_list(cls, text: str, start: int):
if start >= len(text) or text[start] != "[":
raise ValueError("Expected '['")
idx = start + 1
result = []
while idx < len(text):
idx = cls._skip_ws(text, idx)
if idx >= len(text):
break
if text[idx] == "]":
return result, idx + 1
if text[idx] == ",":
idx += 1
continue
value, idx = cls._loose_parse_value(text, idx)
result.append(value)
idx = cls._skip_ws(text, idx)
if idx < len(text) and text[idx] == ",":
idx += 1
return result, idx
@classmethod
def _loose_parse_value(cls, text: str, start: int):
idx = cls._skip_ws(text, start)
if idx >= len(text):
return None, idx
ch = text[idx]
if ch in ('"', "'"):
value, idx = cls._read_loose_quoted_string(text, idx)
return value, idx
if ch == "{":
value, idx = cls._loose_parse_dict(text, idx)
return value, idx
if ch == "[":
value, idx = cls._loose_parse_list(text, idx)
return value, idx
token, idx = cls._read_token(text, idx)
lowered = token.lower()
if lowered in {"null", "none"}:
return None, idx
if lowered == "true":
return True, idx
if lowered == "false":
return False, idx
if re.fullmatch(r"-?\d+", token):
try:
return ast.literal_eval(text)
except (ValueError, SyntaxError):
try:
fixed_text = text.replace("'", '"')
return json.loads(fixed_text)
except json.JSONDecodeError:
pattern = r'"([^"]+)"\s*:\s*"([^"]+)"'
matches = re.findall(pattern, text)
if matches:
return {key: value for key, value in matches}
return {}
return int(token), idx
except ValueError:
return token, idx
if re.fullmatch(r"-?\d+\.\d+", token):
try:
return float(token), idx
except ValueError:
return token, idx
return token, idx
@classmethod
def _skip_ws(cls, text: str, idx: int) -> int:
while idx < len(text) and text[idx].isspace():
idx += 1
return idx
@classmethod
def _read_key(cls, text: str, start: int):
idx = start
ch = text[idx]
if ch in ('"', "'"):
quote = ch
idx += 1
buf = []
while idx < len(text):
cur = text[idx]
if cur == "\\" and idx + 1 < len(text):
buf.append(text[idx + 1])
idx += 2
continue
if cur == quote:
idx += 1
break
buf.append(cur)
idx += 1
return "".join(buf).strip(), idx
buf = []
while idx < len(text):
cur = text[idx]
if cur == ":" or cur.isspace() or cur in ",}":
break
buf.append(cur)
idx += 1
return "".join(buf).strip(), idx
@classmethod
def _read_loose_quoted_string(cls, text: str, start: int):
quote = text[start]
idx = start + 1
buf = []
while idx < len(text):
cur = text[idx]
if cur == "\\" and idx + 1 < len(text):
buf.append(cur)
buf.append(text[idx + 1])
idx += 2
continue
if cur == quote:
look = cls._skip_ws(text, idx + 1)
if look >= len(text) or text[look] in ",}]":
return cls._decode_string("".join(buf), quote), idx + 1
buf.append(cur)
idx += 1
continue
buf.append(cur)
idx += 1
return cls._decode_string("".join(buf), quote), idx
@classmethod
def _decode_string(cls, raw: str, quote: str) -> str:
candidate = f"{quote}{raw}{quote}"
try:
return json.loads(candidate)
except json.JSONDecodeError:
return raw
@classmethod
def _read_token(cls, text: str, start: int):
idx = start
buf = []
while idx < len(text):
cur = text[idx]
if cur in ",}]":
break
buf.append(cur)
idx += 1
return "".join(buf).strip(), idx
@classmethod
def _get_enhanced_keys(cls, key_name: str) -> list[str]:
if not key_name.strip():
return []
key_lower = key_name.lower().strip()
enhanced = [key_name, key_name.upper(), key_name.lower(), key_name.capitalize()]
enhanced = [
key_name,
key_name.upper(),
key_name.lower(),
key_name.capitalize(),
]
mappings = {
"bbox": [
"bbox",
@@ -130,7 +365,15 @@ class TextCustomExtract(io.ComfyNode):
"coord",
"bbox_2d",
],
"label": ["label", "LABEL", "Label", "name", "title", "text", "class"],
"label": [
"label",
"LABEL",
"Label",
"name",
"title",
"text",
"class",
],
"confidence": [
"confidence",
"CONFIDENCE",
@@ -143,8 +386,23 @@ class TextCustomExtract(io.ComfyNode):
"y": ["y", "Y", "pos_y", "position_y"],
"width": ["width", "w", "W", "WIDTH"],
"height": ["height", "h", "H", "HEIGHT"],
"zh": ["zh", "ZH", "chinese", "Chinese", "CHINESE", "中文"],
"en": ["en", "EN", "english", "English", "ENGLISH", "英文", "英语"],
"zh": [
"zh",
"ZH",
"chinese",
"Chinese",
"CHINESE",
"中文",
],
"en": [
"en",
"EN",
"english",
"English",
"ENGLISH",
"英文",
"英语",
],
}
for base, vars in mappings.items():
if key_lower == base or key_name in vars:
@@ -201,5 +459,3 @@ class TextCustomExtract(io.ComfyNode):
return False
label_str = str(label_value).lower()
return any(f.lower() in label_str for f in filters)
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "ComfyUI-1hewNodes"
description = "This is a custom node collection for ComfyUI that provides some utility nodes."
version = "3.2.10"
version = "3.3.0"
license = {file = "LICENSE"}
[project.urls]
+34
View File
@@ -0,0 +1,34 @@
# Mask to SAM3 Box - Mask to SAM3 box prompt
**Node Purpose:** `Mask to SAM3 Box` converts an input mask into bounding-box prompts and outputs a `SAM3_BOXES_PROMPT` structure for SAM3 workflows that require box prompts. It supports positive/negative prompts, merged output, and connected-component split output.
## Inputs
| Name | Port | Type | Default | Range | Description |
| ---- | ---- | ---- | ------- | ----- | ----------- |
| `mask` | - | MASK | - | - | Input mask (batch supported). Pixels with value `> 0.5` are treated as foreground |
| `condition` | - | COMBO | `positive` | `positive` / `negative` | Prompt label; `positive` means foreground boxes, `negative` means exclusion boxes |
| `output_mode` | - | COMBO | `merge` | `merge` / `separate` | `merge` outputs a single merged box; `separate` outputs multiple boxes by connected components |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| `sam3_box` | SAM3_BOXES_PROMPT | SAM3 box prompt; a dict for single mask, and a list of dicts for batched masks |
## Features
- Box extraction:
- `merge`: computes one minimal enclosing rectangle over all foreground pixels.
- `separate`: computes an enclosing rectangle per foreground connected component.
- Normalized format: each box is output as `[cx, cy, bw, bh]` normalized to 0-1 relative to mask width/height.
- Label output: `labels` is a boolean list where `positive` maps to `True` and `negative` maps to `False`, aligned with `boxes`.
## Typical Usage
- Connect a `MASK` from segmentation/detection to this node, then connect `sam3_box` to nodes that accept SAM3 box prompts.
- Use `condition=negative` to exclude areas; use `output_mode=merge` to reduce the number of boxes.
## Notes & Tips
- Mask noise can produce many small boxes in `separate` mode. Consider upstream cleanup or use `merge`.
+34
View File
@@ -0,0 +1,34 @@
# Mask to SAM3 Box - 遮罩转 SAM3 Box 提示
**节点功能:** `Mask to SAM3 Box` 将输入遮罩解析为边界框提示,并输出 `SAM3_BOXES_PROMPT` 结构,适用于需要框提示的 SAM3 工作流。支持正/负提示、合并输出与按连通域拆分输出。
## 输入
| 参数名称 | 入端选择 | 数据类型 | 默认值 | 取值范围 | 描述 |
| -------- | -------- | -------- | ------ | -------- | ---- |
| `mask` | - | MASK | - | - | 输入遮罩(支持批量)。遮罩值按 `> 0.5` 视为前景 |
| `condition` | - | COMBO | `positive` | `positive` / `negative` | 提示标签;`positive` 表示前景框,`negative` 表示排除框 |
| `output_mode` | - | COMBO | `merge` | `merge` / `separate` | `merge` 输出单个合并框;`separate` 按连通域输出多个框 |
## 输出
| 输出名称 | 数据类型 | 描述 |
|---------|----------|------|
| `sam3_box` | SAM3_BOXES_PROMPT | SAM3 框提示;单张遮罩输出字典,批量遮罩输出字典列表 |
## 功能说明
- 框提取:
- `merge`:对全部前景像素取一个整体最小外接矩形。
- `separate`:对前景连通域逐个计算外接矩形。
- 归一化格式:每个框输出为 `[cx, cy, bw, bh]`,均为 0-1 归一化值(相对于遮罩宽高)。
- 标签输出:`labels` 为布尔列表,`positive` 对应 `True`,`negative` 对应 `False`,长度与 `boxes` 一致。
## 典型用法
- 将分割/检测得到的 `MASK` 连接到本节点,再将 `sam3_box` 输出连接到需要 SAM3 框提示的节点。
- 需要排除区域时设置 `condition=negative`;希望减少框数量时使用 `output_mode=merge`。
## 注意与建议
- 遮罩噪声会导致 `separate` 生成大量小框,建议在上游做适度的形态学清理或改用 `merge`。
+28
View File
@@ -0,0 +1,28 @@
# Text to Any - Text to wildcard
**Node Purpose:** `Text to Any` outputs a user-typed text as a wildcard (`*`) payload, enabling more flexible connections (for example, some COMBO inputs can accept a text payload when wired).
## Inputs
| Name | Port | Type | Default | Range | Description |
| ---- | ---- | ---- | ------- | ----- | ----------- |
| `text` | - | STRING | `` | multiline | Input text |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| `any` | * | Wildcard payload containing the input text |
## Features
- Wildcard output: uses `*` to maximize connection compatibility.
- List input support: when `text` is provided as a list, the node uses the first item.
## Typical Usage
- Provide file names, identifiers, or prompt text to downstream nodes via a flexible wildcard connection.
## Notes & Tips
- This node only forwards the text; it does not validate formatting. Keep the value consistent with downstream expectations.
+28
View File
@@ -0,0 +1,28 @@
# Text to Any - 文本转通配类型
**节点功能:** `Text to Any` 将手写文本作为通配类型(`*`)输出,便于连接到更多输入端类型(例如部分节点的 COMBO 参数在连接时也可接收文本载荷)。
## 输入
| 参数名称 | 入端选择 | 数据类型 | 默认值 | 取值范围 | 描述 |
| -------- | -------- | -------- | ------ | -------- | ---- |
| `text` | - | STRING | `` | 多行 | 输入文本 |
## 输出
| 输出名称 | 数据类型 | 描述 |
|---------|----------|------|
| `any` | * | 输出通配类型载荷,内容为输入文本 |
## 功能说明
- 通配输出:输出端使用通配类型 `*`,用于提升连接兼容性。
- 列表输入兼容:当 `text` 以列表形式输入时,节点取第一个元素作为输出。
## 典型用法
- 需要为下游节点提供文件名/标识符/提示词文本时,将 `any` 输出直接连接到对应输入端。
## 注意与建议
- 本节点仅负责传递文本内容,不对内容做格式校验;建议保持与下游节点期望的命名/路径/格式一致。