Files
yawiii-ComfyUI-Prompt-Assis…/node/image_caption_node.py
T
2026-04-25 10:38:46 +08:00

391 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
图像反推(图片反推提示词)节点 - V3 版本
V3 迁移说明:
- 继承 VLMNodeBase + io.ComfyNode
- INPUT_TYPES → define_schema()
- IS_CHANGED → fingerprint_inputs()
- def analyze_image(self, ...) → @classmethod execute(cls, ...)
功能说明:
- 支持单张图像输入
- 支持 IMAGE batch 输入:逐帧独立调用 VLM,输出合并文本 + 字符串列表
- 两个输出端口:
· caption_text (STRING) - 所有帧结果用 "\\n---\\n" 合并的完整文本
· caption_list (STRING List) - 每帧独立结果的字符串列表(is_output_list=True)
"""
from comfy.model_management import InterruptProcessingException
from comfy_api.latest import io
from ..services.vlm import VisionService
from ..utils.common import (
format_api_error, format_model_with_thinking, generate_request_id,
log_prepare, log_error, TASK_IMAGE_CAPTION, SOURCE_NODE
)
from ..services.thinking_control import build_thinking_suppression
from .base import VLMNodeBase
class ImageCaptionNode(VLMNodeBase, io.ComfyNode):
"""图像反推节点(V3),支持单张/批量图像输入,输出合并文本与字符串列表"""
@classmethod
def define_schema(cls):
# 动态获取服务列表
service_options = cls.get_vlm_service_options()
default_service = service_options[0] if service_options else "智谱"
# 获取模板
from ..config_manager import config_manager
system_prompts = config_manager.get_system_prompts()
vision_prompts = {}
active_vision_id = None
if system_prompts:
vision_prompts = system_prompts.get('vision_prompts', {}) or {}
active_vision_id = system_prompts.get('active_prompts', {}).get('vision')
prompt_template_options = []
id_to_display_name = {}
for key, value in vision_prompts.items():
show_in = value.get('showIn', ["frontend", "node"])
if 'node' not in show_in:
continue
name = value.get('name', key)
category = value.get('category', '')
display_name = f"{category}/{name}" if category else name
id_to_display_name[key] = display_name
prompt_template_options.append(display_name)
default_template_name = prompt_template_options[0] if prompt_template_options else "反推-自然语言"
if active_vision_id and active_vision_id in id_to_display_name:
default_template_name = id_to_display_name[active_vision_id]
if not prompt_template_options:
prompt_template_options = ["反推-自然语言"]
return io.Schema(
node_id="ImageCaptionNode",
display_name="✨Image Caption (VLM)",
category="✨Prompt Assistant",
description="Extract text prompt from image using Vision-Language Models",
inputs=[
io.Image.Input(
"image",
tooltip="The image to analyze. Supports single image or IMAGE batch (processes each frame independently)"
),
io.Combo.Input(
"rule",
options=prompt_template_options,
default=default_template_name,
tooltip="Choose a preset rule for analysis"
),
io.Boolean.Input(
"custom_rule",
default=False,
label_on="Enable",
label_off="Disable",
tooltip="Enable custom rule input"
),
io.String.Input(
"custom_rule_content",
multiline=True,
default="",
tooltip="Custom rule content, only used when Custom Rule is enabled"
),
io.String.Input(
"user_prompt",
multiline=True,
default="",
tooltip="Enter additional prompts here, sent with the rule"
),
io.Combo.Input(
"vlm_service",
options=service_options,
default=default_service,
tooltip="Select VLM Service"
),
io.Boolean.Input(
"ollama_auto_unload",
default=True,
label_on="Enable",
label_off="Disable",
tooltip="Auto unload Ollama model after generation"
),
io.Int.Input(
"seed",
default=0,
min=0,
max=0xffffffffffffffff,
control_after_generate=True,
tooltip="Controls randomness. Set to non-fixed mode to force re-execution"
),
],
outputs=[
# 合并文本(单张直接输出;batch 时用 "\n---\n" 拼接)
io.String.Output("caption_text"),
# 每帧独立结果列表(单张时为单元素列表)
io.String.Output("caption_list", is_output_list=True),
],
hidden=[io.Hidden.unique_id],
)
# -------------------------------------------------------------------------
# fingerprint_inputs(替代 IS_CHANGED)
# -------------------------------------------------------------------------
@classmethod
def fingerprint_inputs(
cls,
image=None, rule=None, custom_rule=None, custom_rule_content=None,
user_prompt=None, vlm_service=None, ollama_auto_unload=None, seed=None
):
"""替代 V1 IS_CHANGED"""
import hashlib
temp_rule_hash = hashlib.md5((custom_rule_content or "").encode('utf-8')).hexdigest()
user_hint_hash = hashlib.md5((user_prompt or "").encode('utf-8')).hexdigest()
img_hash = cls._compute_image_hash(image)
return hash((
img_hash,
rule,
bool(custom_rule),
temp_rule_hash,
user_hint_hash,
vlm_service,
bool(ollama_auto_unload),
seed
))
# -------------------------------------------------------------------------
# 内部辅助:分析单张图像
# -------------------------------------------------------------------------
@classmethod
def _analyze_single_image(
cls,
image_data: str,
prompt_to_send: str,
rule_name: str,
service_id: str,
service: dict,
provider_config: dict,
request_id: str,
frame_index=None
) -> str:
"""
对单张图像调用 VLM 进行分析,返回文本结果。
参数:
image_data : base64 编码的图像 data URL
prompt_to_send: 拼接好的提示词(system + user_prompt)
rule_name : 当前规则名称(用于日志)
service_id : 服务 ID
service : 服务配置字典
provider_config: API 调用参数字典
request_id : 本次请求 ID
frame_index : batch 模式下当前帧序号(None 表示单张模式)
返回:
分析结果文本字符串
"""
frame_label = f" [Frame {frame_index + 1}]" if frame_index is not None else ""
model_full_name = provider_config.get('model')
disable_thinking_enabled = service.get('disable_thinking', True)
thinking_extra = (
build_thinking_suppression(service_id, model_full_name)
if disable_thinking_enabled else None
)
model_display = format_model_with_thinking(model_full_name, bool(thinking_extra))
service_display_name = service.get('name', service_id)
log_prepare(
TASK_IMAGE_CAPTION, request_id, SOURCE_NODE,
service_display_name, model_display, rule_name + frame_label
)
result = cls._run_vision_task(
VisionService.analyze_image,
service_id,
image_data=image_data,
prompt_content=prompt_to_send,
request_id=request_id,
custom_provider=service_id,
custom_provider_config=provider_config,
source=SOURCE_NODE
)
if result and result.get('success'):
data = result.get('data', {})
# V3 重构后返回键名为 description,兼容旧版 caption
caption_text = data.get('description', data.get('caption', '')).strip()
if not caption_text:
error_msg = 'API returned empty result'
log_error(TASK_IMAGE_CAPTION, request_id, error_msg, source=SOURCE_NODE)
raise RuntimeError(f"Analysis failed: {error_msg}")
return caption_text
else:
error_msg = (
result.get('error', 'Unknown error') if result
else 'No result returned'
)
if error_msg == "任务被中断":
raise InterruptProcessingException()
log_error(TASK_IMAGE_CAPTION, request_id, error_msg, source=SOURCE_NODE)
raise RuntimeError(f"Analysis failed: {error_msg}")
# -------------------------------------------------------------------------
# execute(V3 主执行方法)
# -------------------------------------------------------------------------
@classmethod
def execute(
cls,
image, rule, custom_rule, custom_rule_content,
user_prompt, vlm_service, ollama_auto_unload, seed=None
):
unique_id = cls.hidden.unique_id
request_id = None
try:
if image is None:
raise ValueError("No image provided. Please connect an image to the 'image' input.")
# ------------------------------------------------------------------
# 1. 构建提示词
# ------------------------------------------------------------------
rule_name = "Custom Rule" if (custom_rule and custom_rule_content) else rule
system_message = None
if custom_rule and custom_rule_content:
system_message = {"role": "system", "content": custom_rule_content}
else:
from ..config_manager import config_manager
system_prompts = config_manager.get_system_prompts()
vision_prompts = system_prompts.get('vision_prompts', {}) if system_prompts else {}
template_found = False
for key, value in vision_prompts.items():
name = value.get('name', key)
category = value.get('category', '')
display_name = f"{category}/{name}" if category else name
if display_name == rule or value.get('name') == rule or key == rule:
system_message = {
"role": value.get('role', 'system'),
"content": value.get('content', '')
}
template_found = True
break
if not template_found or not system_message or not system_message.get('content'):
system_message = {"role": "system", "content": "请详细描述这张图片的内容"}
rule_name = "Default Rule"
system_text = (
system_message.get('content', '')
if isinstance(system_message, dict)
else str(system_message)
)
prompt_to_send = f"{system_text}\n\n{user_prompt}".strip() if user_prompt else system_text
# ------------------------------------------------------------------
# 2. 解析服务与模型
# ------------------------------------------------------------------
service_id, model_name = cls.parse_service_model(vlm_service)
if not service_id:
raise ValueError(f"Invalid service selection: {vlm_service}")
from ..config_manager import config_manager
service = config_manager.get_service(service_id)
if not service:
raise ValueError(f"Service config not found: {vlm_service}")
vlm_models = service.get('vlm_models', [])
target_model = None
if model_name:
target_model = next((m for m in vlm_models if m.get('name') == model_name), None)
if not target_model:
target_model = next(
(m for m in vlm_models if m.get('is_default')),
vlm_models[0] if vlm_models else None
)
if not target_model:
raise ValueError(f"Service {vlm_service} has no available vision models")
provider_config = {
'provider': service_id,
'model': target_model.get('name', ''),
'base_url': service.get('base_url', ''),
'api_key': service.get('api_key', ''),
'temperature': target_model.get('temperature', 0.7),
'max_tokens': target_model.get('max_tokens', 1000),
'top_p': target_model.get('top_p', 0.9),
}
if service.get('type') == 'ollama':
provider_config['auto_unload'] = ollama_auto_unload
if not provider_config.get('model', ''):
raise ValueError(f"Please configure model for {vlm_service}")
if cls._service_requires_api_key(service) and not provider_config.get('api_key', ''):
raise ValueError(f"Please configure API key and model for {vlm_service}")
# ------------------------------------------------------------------
# 3. 分析图像:支持 batch(逐帧独立调用)
# ------------------------------------------------------------------
request_id = generate_request_id("vlm", None, unique_id)
# 检查是否为多帧 batch [N, H, W, C] 且 N > 1
if len(image.shape) == 4 and image.shape[0] > 1:
# Batch 模式:逐帧独立调用 VLM
batch_size = image.shape[0]
results: list[str] = []
for i in range(batch_size):
single_frame = image[i:i + 1] # 保持 4D 形状 [1, H, W, C]
image_data = cls._image_to_base64(single_frame)
frame_provider_config = provider_config
if service.get('type') == 'ollama':
frame_provider_config = provider_config.copy()
frame_provider_config['auto_unload'] = bool(ollama_auto_unload) and i == batch_size - 1
# 为每帧生成独立的 request_id,便于日志追踪
frame_request_id = generate_request_id("vlm", None, f"{unique_id}_f{i}")
description = cls._analyze_single_image(
image_data=image_data,
prompt_to_send=prompt_to_send,
rule_name=rule_name,
service_id=service_id,
service=service,
provider_config=frame_provider_config,
request_id=frame_request_id,
frame_index=i
)
results.append(description)
# 合并文本(与旧版保持一致)
combined_text = "\n---\n".join(results)
return io.NodeOutput(combined_text, results)
else:
# 单张模式
image_data = cls._image_to_base64(image)
description = cls._analyze_single_image(
image_data=image_data,
prompt_to_send=prompt_to_send,
rule_name=rule_name,
service_id=service_id,
service=service,
provider_config=provider_config,
request_id=request_id,
frame_index=None
)
# 单张时 caption_list 为单元素列表
return io.NodeOutput(description, [description])
except InterruptProcessingException:
raise
except Exception as e:
error_msg = format_api_error(e, vlm_service)
log_error(TASK_IMAGE_CAPTION, request_id, error_msg, source=SOURCE_NODE)
raise RuntimeError(f"Analysis error: {error_msg}")