Files
TTPlanetPig-Comfyui_Object_…/nodes.py

365 lines
12 KiB
Python

import os
import ast
import json
from dataclasses import dataclass
from typing import List, Dict, Any, Tuple
import torch
from PIL import Image
from huggingface_hub import snapshot_download
from transformers import (
AutoProcessor,
Qwen2_5_VLForConditionalGeneration,
BitsAndBytesConfig,
)
import folder_paths
def parse_json(json_output: str) -> str:
"""Extract the JSON payload from a model response string."""
if "```json" in json_output:
json_output = json_output.split("```json", 1)[1]
json_output = json_output.split("```", 1)[0]
try:
parsed = json.loads(json_output)
if isinstance(parsed, dict) and "content" in parsed:
inner = parsed["content"]
if isinstance(inner, str):
json_output = inner
except Exception:
pass
return json_output
def parse_boxes(
text: str,
img_width: int,
img_height: int,
input_w: int,
input_h: int,
score_threshold: float = 0.0,
) -> List[Dict[str, Any]]:
"""Return bounding boxes parsed from the model's raw JSON output."""
text = parse_json(text)
try:
data = json.loads(text)
except Exception:
try:
data = ast.literal_eval(text)
except Exception:
end_idx = text.rfind('"}') + len('"}')
truncated = text[:end_idx] + "]"
data = ast.literal_eval(truncated)
if isinstance(data, dict):
inner = data.get("content")
if isinstance(inner, str):
try:
data = ast.literal_eval(inner)
except Exception:
data = []
else:
data = []
items: List[DetectedBox] = []
x_scale = img_width / input_w
y_scale = img_height / input_h
for item in data:
box = item.get("bbox_2d") or item.get("bbox") or item
label = item.get("label", "")
score = float(item.get("score", 1.0))
y1, x1, y2, x2 = box[1], box[0], box[3], box[2]
abs_y1 = int(y1 * y_scale)
abs_x1 = int(x1 * x_scale)
abs_y2 = int(y2 * y_scale)
abs_x2 = int(x2 * x_scale)
if abs_x1 > abs_x2:
abs_x1, abs_x2 = abs_x2, abs_x1
if abs_y1 > abs_y2:
abs_y1, abs_y2 = abs_y2, abs_y1
if score >= score_threshold:
items.append(DetectedBox([abs_x1, abs_y1, abs_x2, abs_y2], score, label))
items.sort(key=lambda x: x.score, reverse=True)
return [
{"score": b.score, "bbox": b.bbox, "label": b.label}
for b in items
]
@dataclass
class DetectedBox:
bbox: List[int]
score: float
label: str = ""
@dataclass
class QwenModel:
model: Any
processor: Any
device: str
class DownloadAndLoadQwenModel:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model_name": ([
"Qwen/Qwen2.5-VL-3B-Instruct",
"Qwen/Qwen2.5-VL-7B-Instruct",
"Qwen/Qwen2.5-VL-32B-Instruct",
"Qwen/Qwen2.5-VL-72B-Instruct",
], ),
"device": ([
"auto",
"cuda:0",
"cuda:1",
"cpu",
], ),
"precision": ([
"INT4",
"INT8",
"BF16",
"FP16",
"FP32",
], ),
"attention": ([
"flash_attention_2",
"sdpa",
], ),
}
}
RETURN_TYPES = ("QWEN_MODEL",)
RETURN_NAMES = ("qwen_model",)
FUNCTION = "load"
CATEGORY = "Qwen2.5-VL"
def load(self, model_name: str, device: str, precision: str, attention: str):
model_dir = os.path.join(folder_paths.models_dir, "Qwen", model_name.replace("/", "_"))
# Always attempt download with resume enabled so an interrupted download
# can be continued when the node is executed again.
snapshot_download(
repo_id=model_name,
local_dir=model_dir,
local_dir_use_symlinks=False,
resume_download=True,
)
if device == "auto":
device_map = "auto"
elif device == "cpu":
device_map = {"": "cpu"}
else:
device_map = {"": device}
precision = precision.upper()
dtype_map = {
"BF16": torch.bfloat16,
"FP16": torch.float16,
"FP32": torch.float32,
}
torch_dtype = dtype_map.get(precision, torch.bfloat16)
quant_config = None
if precision == "INT4":
quant_config = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_use_double_quant=True)
elif precision == "INT8":
quant_config = BitsAndBytesConfig(load_in_8bit=True)
attn_impl = attention
if precision == "FP32" and attn_impl == "flash_attention_2":
# FlashAttention doesn't support fp32. Fall back to SDPA.
attn_impl = "sdpa"
try:
model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
model_dir,
torch_dtype=torch_dtype,
quantization_config=quant_config,
device_map=device_map,
attn_implementation=attn_impl,
)
except OSError:
# If loading fails due to missing or corrupt files, force a
# re-download and try again.
snapshot_download(
repo_id=model_name,
local_dir=model_dir,
local_dir_use_symlinks=False,
resume_download=True,
force_download=True,
)
model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
model_dir,
torch_dtype=torch_dtype,
quantization_config=quant_config,
device_map=device_map,
attn_implementation=attn_impl,
)
except Exception:
# Surface any other errors (e.g. missing flash_attn)
raise
processor = AutoProcessor.from_pretrained(model_dir)
return (QwenModel(model=model, processor=processor, device=device),)
class QwenVLDetection:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"qwen_model": ("QWEN_MODEL",),
"image": ("IMAGE",),
"target": ("STRING", {"default": "object"}),
"bbox_selection": ("STRING", {"default": "all"}),
"score_threshold": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"merge_boxes": ("BOOLEAN", {"default": False}),
},
}
RETURN_TYPES = ("JSON", "BBOX")
RETURN_NAMES = ("text", "bboxes")
FUNCTION = "detect"
CATEGORY = "Qwen2.5-VL"
def detect(
self,
qwen_model: QwenModel,
image,
target: str,
bbox_selection: str = "all",
score_threshold: float = 0.0,
merge_boxes: bool = False,
):
"""Generate bounding boxes for ``target`` within ``image``."""
model = qwen_model.model
processor = qwen_model.processor
device = qwen_model.device
if device == "auto":
device = str(next(model.parameters()).device)
if device.startswith("cuda") and torch.cuda.is_available():
try:
torch.cuda.set_device(int(device.split(":")[1]))
except Exception:
pass
prompt = f"Locate the {target} and output bbox in JSON"
if isinstance(image, torch.Tensor):
image = (image.squeeze().clamp(0, 1) * 255).to(torch.uint8).cpu().numpy()
image = Image.fromarray(image)
elif not isinstance(image, Image.Image):
raise ValueError("Unsupported image type")
messages = [
{"role": "system", "content": "You are a helpful assistant"},
{"role": "user", "content": [{"type": "text", "text": prompt}, {"image": image}]},
]
text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = processor(text=[text], images=[image], return_tensors="pt", padding=True)
# Avoid keeping metadata tensors such as ``image_grid_thw`` on the GPU to
# prevent CUDA assertion failures on newer PyTorch builds when converting
# them to Python scalars for box rescaling. Fall back to pixel values or
# raw image dimensions if the grid is missing or produces invalid sizes.
input_h = None
input_w = None
image_grid = inputs.get("image_grid_thw")
if image_grid is not None:
image_grid = image_grid.detach().cpu()
if image_grid.numel() >= 3:
h_val = int(image_grid[0][1].item() * 14)
w_val = int(image_grid[0][2].item() * 14)
if h_val > 0 and w_val > 0:
input_h, input_w = h_val, w_val
if input_h is None or input_w is None:
pixel_values = inputs.get("pixel_values")
if pixel_values is not None:
input_h = int(pixel_values.shape[-2])
input_w = int(pixel_values.shape[-1])
else:
input_w, input_h = image.size
input_h = max(1, input_h)
input_w = max(1, input_w)
inputs = inputs.to(device)
with torch.no_grad():
output_ids = model.generate(**inputs, max_new_tokens=1024)
gen_ids = [output_ids[len(inp):] for inp, output_ids in zip(inputs.input_ids, output_ids)]
output_text = processor.batch_decode(
gen_ids, skip_special_tokens=True, clean_up_tokenization_spaces=True
)[0]
items = parse_boxes(
output_text,
image.width,
image.height,
input_w,
input_h,
score_threshold,
)
selection = bbox_selection.strip().lower()
boxes = items
if selection != "all" and selection:
idxs = []
for part in selection.replace(",", " ").split():
try:
idxs.append(int(part))
except Exception:
continue
boxes = [boxes[i] for i in idxs if 0 <= i < len(boxes)]
if merge_boxes and boxes:
x1 = min(b["bbox"][0] for b in boxes)
y1 = min(b["bbox"][1] for b in boxes)
x2 = max(b["bbox"][2] for b in boxes)
y2 = max(b["bbox"][3] for b in boxes)
score = max(b["score"] for b in boxes)
label = boxes[0].get("label", target)
boxes = [{"bbox": [x1, y1, x2, y2], "score": score, "label": label}]
json_boxes = [
{"bbox_2d": b["bbox"], "label": b.get("label", target)} for b in boxes
]
json_output = json.dumps(json_boxes, ensure_ascii=False)
bboxes_only = [b["bbox"] for b in boxes]
return (json_output, bboxes_only)
class BBoxesToSAM2:
"""Convert a list of bounding boxes to the format expected by SAM2 nodes."""
@classmethod
def INPUT_TYPES(cls):
return {"required": {"bboxes": ("BBOX",)}}
RETURN_TYPES = ("BBOXES",)
RETURN_NAMES = ("sam2_bboxes",)
FUNCTION = "convert"
CATEGORY = "Qwen2.5-VL"
def convert(self, bboxes):
if not isinstance(bboxes, list):
raise ValueError("bboxes must be a list")
# If already batched, return as-is
if bboxes and isinstance(bboxes[0], (list, tuple)) and bboxes[0] and isinstance(bboxes[0][0], (list, tuple)):
return (bboxes,)
return ([bboxes],)
NODE_CLASS_MAPPINGS = {
"DownloadAndLoadQwenModel": DownloadAndLoadQwenModel,
"QwenVLDetection": QwenVLDetection,
"BBoxesToSAM2": BBoxesToSAM2,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"DownloadAndLoadQwenModel": "Download and Load Qwen2.5-VL Model",
"QwenVLDetection": "Qwen2.5-VL Object Detection",
"BBoxesToSAM2": "Prepare BBoxes for SAM2",
}