diff --git a/README.ZH_CN.md b/README.ZH_CN.md index cf07239..17d979a 100644 --- a/README.ZH_CN.md +++ b/README.ZH_CN.md @@ -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 | 将文本输出为通配类型,便于连接到更多输入端 | ### 🧠 逻辑节点 | 节点名称 | 功能描述 | diff --git a/README.md b/README.md index 3af749d..fef7ccd 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/imgs/Stargazers.png b/imgs/Stargazers.png index df61a7f..945c0f8 100644 --- a/imgs/Stargazers.png +++ b/imgs/Stargazers.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:968e531a2abe373d5c52c6c3a10beba1d3fc4fabce2c27e515aa6b97dd7be71e -size 117824 +oid sha256:12effe04a03c1c0b4a2a1c0a9faedbb40189fedd8dec8b2996d6d75b3786633d +size 133970 diff --git a/nodes/conversion/mask_to_sam3_box.py b/nodes/conversion/mask_to_sam3_box.py new file mode 100644 index 0000000..b4ecfc8 --- /dev/null +++ b/nodes/conversion/mask_to_sam3_box.py @@ -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 diff --git a/nodes/conversion/text_to_any.py b/nodes/conversion/text_to_any.py new file mode 100644 index 0000000..6c4bffa --- /dev/null +++ b/nodes/conversion/text_to_any.py @@ -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) diff --git a/nodes/text/text_custom_extract.py b/nodes/text/text_custom_extract.py index 36c8eaf..223f260 100644 --- a/nodes/text/text_custom_extract.py +++ b/nodes/text/text_custom_extract.py @@ -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) - - diff --git a/pyproject.toml b/pyproject.toml index 6d5272e..fd6848e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/web/docs/1hew_MaskToSam3Box/en.md b/web/docs/1hew_MaskToSam3Box/en.md new file mode 100644 index 0000000..1ed2897 --- /dev/null +++ b/web/docs/1hew_MaskToSam3Box/en.md @@ -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`. diff --git a/web/docs/1hew_MaskToSam3Box/zh.md b/web/docs/1hew_MaskToSam3Box/zh.md new file mode 100644 index 0000000..5464d5d --- /dev/null +++ b/web/docs/1hew_MaskToSam3Box/zh.md @@ -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`。 diff --git a/web/docs/1hew_TextToAny/en.md b/web/docs/1hew_TextToAny/en.md new file mode 100644 index 0000000..f9d606a --- /dev/null +++ b/web/docs/1hew_TextToAny/en.md @@ -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. diff --git a/web/docs/1hew_TextToAny/zh.md b/web/docs/1hew_TextToAny/zh.md new file mode 100644 index 0000000..0ea498c --- /dev/null +++ b/web/docs/1hew_TextToAny/zh.md @@ -0,0 +1,28 @@ +# Text to Any - 文本转通配类型 + +**节点功能:** `Text to Any` 将手写文本作为通配类型(`*`)输出,便于连接到更多输入端类型(例如部分节点的 COMBO 参数在连接时也可接收文本载荷)。 + +## 输入 + +| 参数名称 | 入端选择 | 数据类型 | 默认值 | 取值范围 | 描述 | +| -------- | -------- | -------- | ------ | -------- | ---- | +| `text` | - | STRING | `` | 多行 | 输入文本 | + +## 输出 + +| 输出名称 | 数据类型 | 描述 | +|---------|----------|------| +| `any` | * | 输出通配类型载荷,内容为输入文本 | + +## 功能说明 + +- 通配输出:输出端使用通配类型 `*`,用于提升连接兼容性。 +- 列表输入兼容:当 `text` 以列表形式输入时,节点取第一个元素作为输出。 + +## 典型用法 + +- 需要为下游节点提供文件名/标识符/提示词文本时,将 `any` 输出直接连接到对应输入端。 + +## 注意与建议 + +- 本节点仅负责传递文本内容,不对内容做格式校验;建议保持与下游节点期望的命名/路径/格式一致。