detailer enable vl models

Signed-off-by: Vladimir Mandic <mandic00@live.com>
This commit is contained in:
Vladimir Mandic
2026-08-08 16:24:00 +02:00
parent 44eebcd19b
commit e10ec40e2c
8 changed files with 224 additions and 12 deletions
+11 -5
View File
@@ -38,7 +38,10 @@ class Detailer():
) -> list[DetailerResult]:
if 'LocateAnything' in name:
from modules.detailer import locateanything
return locateanything.predict(self, model, image, device=device, mask=mask, offload=offload, p=p)
return locateanything.predict(self, name, image, device=device, mask=mask, offload=offload, p=p)
if 'Qwen3-VL' in name:
from modules.detailer import qwen
return qwen.predict(self, name, image, device=device, mask=mask, offload=offload, p=p)
from modules.detailer import yolo
return yolo.predict(self, model, image, imgsz=imgsz, half=half, device=device, agnostic=agnostic, retina=retina, mask=mask, augment=augment, offload=offload, p=p)
@@ -50,7 +53,10 @@ class Detailer():
def load(self, model_name: str | None = None):
if 'LocateAnything' in model_name:
from modules.detailer import locateanything
return locateanything.load(model_name=model_name)
return locateanything.load(self, model_name=model_name)
if 'Qwen3-VL' in model_name:
from modules.detailer import qwen
return qwen.load(self, model_name=model_name)
from modules.detailer import yolo
return yolo.load(self, model_name=model_name)
@@ -356,7 +362,7 @@ class Detailer():
return gr.update(visible=False), gr.update(visible=True, value=value), gr.update(visible=False)
def ui(self, tab: str):
def ui_settings_change(merge, detailers, text, classes, strength, padding, blur, min_confidence, max_detected, min_size, max_size, iou, steps, renoise_value, renoise_end, resolution, save, sort, seg):
def ui_settings_change(merge, detailers, text, classes, strength, padding, blur, min_confidence, max_detected, min_size, max_size, iou, steps, renoise_value, renoise_end, resolution, save, sort, seg): # pylint: disable=unused-argument
shared.opts.detailer_merge = merge
shared.opts.detailer_models = detailers
shared.opts.detailer_args = text if not self.ui_mode else ''
@@ -375,7 +381,7 @@ class Detailer():
shared.opts.detailer_segmentation = seg
# shared.opts.detailer_resolution = resolution
shared.opts.save(silent=True)
log.debug(f'Detailer settings: models={detailers} classes={classes} strength={strength} conf={min_confidence} max={max_detected} iou={iou} size={min_size}-{max_size} padding={padding} steps={steps} resolution={resolution} save={save} sort={sort} seg={seg}')
# log.debug(f'Detailer settings: models={detailers} classes={classes} strength={strength} conf={min_confidence} max={max_detected} iou={iou} size={min_size}-{max_size} padding={padding} steps={steps} resolution={resolution} save={save} sort={sort} seg={seg}')
if not self.ui_mode:
log.debug(f'Detailer expert: {text}')
@@ -395,7 +401,7 @@ class Detailer():
ui_mode = ui_components.ToolButton(value=ui_symbols.view, elem_id=f'{tab}_yolo_models_list')
ui_mode.click(fn=self.change_mode, inputs=[detailers, detailers_text], outputs=[detailers, detailers_text, refresh_btn])
with gr.Row():
classes = gr.Textbox(label="Detailer classes", placeholder="Classes", elem_id=f"{tab}_detailer_classes")
classes = gr.Textbox(label="Detailer classes or instructions", placeholder="List of classes or human instructions", elem_id=f"{tab}_detailer_classes")
if tab == 'extras': # Process tab is standalone, there is no base prompt to fall back to
prompt_placeholder = 'detailer prompt, leave empty for none'
negative_placeholder = 'detailer negative prompt, leave empty for none'
+3 -3
View File
@@ -44,9 +44,9 @@ class DetailerResult:
self.box = box
self.mask = mask
self.item = item
self.width = width
self.height = height
self.width = width if width > 0 else box[2] - box[0]
self.height = height if height > 0 else box[3] - box[1]
self.args = args
def __str__(self):
return f'DetailerResult(cls={self.cls} label={self.label} score={self.score:.2f} box={self.box} size={self.width}x{self.height} args={self.args})'
return f'DetailerResult(cls={self.cls} label="{self.label}" score={self.score:.2f} box={self.box} size={self.width}x{self.height} args={self.args})'
+4 -3
View File
@@ -14,7 +14,7 @@ def dependencies():
install('decord')
def load(model_name: str | None = None):
def load(self, model_name: str | None = None):
import transformers
global tokenizer, processor # pylint: disable=global-statement
load_kwargs = {
@@ -30,6 +30,7 @@ def load(model_name: str | None = None):
if shared.opts.detailer_unload:
model.to(devices.cpu)
log.info(f'Detailer model="{model_name}" cls={model.__class__.__name__} loaded')
self.models[model_name] = model
return model_name, model
@@ -44,16 +45,16 @@ def predict(
) -> list[DetailerResult]:
if offload is None:
offload = shared.opts.detailer_unload
log.info(f'Detailer cls="{model.__class__.__name__}" image={image} device={device} mask={mask} offload={offload}')
result = []
if isinstance(model, str):
cached = self.models.get(model, None)
if cached is None:
_, model = self.load(model)
_, model = load(self, model)
else:
model = cached
if model is None:
return result
log.info(f'Detailer cls="{model.__class__.__name__}" image={image} device={device} mask={mask} offload={offload}')
model = model.to(device)
prompt = detailer_opt(p, 'detailer_classes') or ''
log.debug(f'Detailer prompt="{prompt}"')
+3
View File
@@ -9,5 +9,8 @@ detailer_models = [ # <https://huggingface.co/vladmandic/yolo-detailers/tree/mai
'https://huggingface.co/vladmandic/yolo-detailers/resolve/main/anzhc-eyes-seg.pt',
'https://huggingface.co/vladmandic/yolo-detailers/resolve/main/anzhc-face-1024-seg-8n.pt',
'https://huggingface.co/vladmandic/yolo-detailers/resolve/main/anzhc-head-seg-8n.pt',
'Qwen3-VL-2B-Instruct',
'Qwen3-VL-4B-Instruct',
'Qwen3-VL-8B-Instruct',
# 'nvidia-LocateAnything-3B',
]
+186
View File
@@ -0,0 +1,186 @@
import time
import json
import transformers
from pydantic import BaseModel, Field
from PIL import Image, ImageDraw
from modules import shared, devices, sd_offload_aux, model_quant
from modules.detailer import DetailerResult, detailer_opt
from modules.logger import log
class BoundingBoxItem(BaseModel):
label: str = Field(..., description="Label of the detected object")
confidence: float = Field(default=1.0, ge=0.0, le=1.0, description="Confidence score between 0.0 and 1.0")
box_2d: list[int] = Field(..., min_items=4, max_items=4, description="Bounding box coordinates in 0-1000 normalized format: [ymin, xmin, ymax, xmax]")
class ObjectDetectionOutput(BaseModel):
response: str = Field(..., description="Reasoning summary: state which requested objects were identified and their location before outputting coordinates.")
objects: list[BoundingBoxItem]
def template(prompt: str, schema: str, min_confidence: float) -> list[dict]:
def confidence() -> str:
conf = max(0.0, min(1.0, float(min_confidence)))
if conf >= 0.85:
instruction = "Detect only obvious, fully visible target objects."
elif conf >= 0.65:
instruction = "Detect clear targets, ignoring faint or ambiguous cases."
elif conf >= 0.45:
instruction = "Detect all distinct targets, including partially covered ones."
elif conf >= 0.25:
instruction = "Detect candidate targets, including small, blurry, or occluded ones."
else:
instruction = "Detect all possible target candidates, background objects, or fragments."
return instruction
instructions = (
"You are an expert vision assistant for object detection.\n"
f"INSTRUCTIONS:\n"
f"1. Scan the image carefully for EACH requested class.\n"
f"2. Add a BoundingBoxItem to 'objects' for EVERY instance found.\n"
f"3. In the 'response' field, explicitly list which target classes were found and which were missing.\n"
f"4. {confidence()}\n"
f"5. You MUST respond strictly with a valid JSON object matching this schema: \n```json\n{schema}\n```\n\n"
"6. Do not include any Markdown text outside of the JSON string. Add any text explanations or clarifications inside the 'response' field of the JSON object.\n"
"7. Bounding box coordinates must be in 0-1000 normalized format: [ymin, xmin, ymax, xmax]."
)
return [
{ "role": "system", "content": instructions },
{ "role": "user",
"content": [
{"type": "image"}, # injected later using processor
{"type": "text", "text": prompt},
],
},
]
def load(self, model_name: str | None = None) -> tuple[str, transformers.Qwen3VLForConditionalGeneration]: # pylint: disable=unused-argument
cached = sd_offload_aux.get_aux_model(model_name)
if cached is not None:
return model_name, cached
repo_id = 'Qwen/' + model_name if not model_name.startswith('Qwen/') else model_name
load_kwargs = {
'pretrained_model_name_or_path': repo_id,
'cache_dir': shared.opts.hfcache_dir,
'torch_dtype': devices.dtype,
}
quant_args = model_quant.create_config(module='LLM', modules_to_not_convert=['conv1d', 'linear_attn.conv1d'])
model = transformers.Qwen3VLForConditionalGeneration.from_pretrained(**load_kwargs, **quant_args, attn_implementation="sdpa")
model = model.eval()
model.processor: transformers.Qwen3VLProcessor = transformers.Qwen3VLProcessor.from_pretrained(**load_kwargs)
sd_offload_aux.register_aux(model_name, model)
if shared.opts.detailer_unload:
sd_offload_aux.offload_aux(model_name)
log.info(f'Load: type=Detailer name="{model_name}" cls="{model.__class__.__name__}" processor="{model.processor.__class__.__name__}"')
return model_name, model
def parse(data: str, image: Image.Image) -> tuple[str, list[DetailerResult]]:
results = []
response = ''
w, h = image.size
try:
clean = data.strip()
if clean.startswith("```json"):
clean = clean[7:]
if clean.endswith("```"):
clean = clean[:-3]
parsed = json.loads(clean.strip())
response = parsed.get("response", "")
objects = parsed.get("objects", [])
for item in objects:
box_2d = item.get("box_2d", [])
label = item.get("label", "")
confidence = float(item.get("confidence", 1.0))
if len(box_2d) == 4:
xmin, ymin, xmax, ymax = map(int, box_2d)
xmin, ymin = int((xmin / 1000.0) * w), int((ymin / 1000.0) * h)
xmax, ymax = int((xmax / 1000.0) * w), int((ymax / 1000.0) * h)
box = (xmin, ymin, xmax, ymax)
mask = Image.new('L', image.size, 0)
draw_mask = ImageDraw.Draw(mask)
draw_mask.rectangle(box, fill="white", outline=None, width=0)
result = DetailerResult(box=box, label=label, score=confidence, cls=-1, mask=mask)
log.trace(f'Detailer box: {result}')
results.append(result)
except Exception as err:
log.error(f'Detailer: failed to parse object detection output: {err}')
log.error(f'Detailer: raw output: {data}')
return response, results
def predict(
self,
name: str,
image: Image.Image,
device = devices.device,
mask: bool = True,
offload: bool | None = None,
p = None,
) -> list[DetailerResult]:
if offload is None:
offload = shared.opts.detailer_unload
if image is None:
return []
cached = sd_offload_aux.get_aux_model(name)
if cached is None:
name, model = load(self, name)
else:
model = cached
if model is None:
return []
prompt = detailer_opt(p, 'detailer_classes') or ''
if not prompt:
prompt = 'Detect and locate all objects'
log.debug(f'Detailer: name="{name}" cls={model.__class__.__name__} prompt="{prompt}" image={image.size} device={device} mask={mask} offload={offload}')
sd_offload_aux.move_aux_to_gpu(name)
t0 = time.time()
schema = json.dumps(ObjectDetectionOutput.model_json_schema(), indent=2)
messages = template(prompt=prompt, schema=schema, min_confidence=shared.opts.detailer_conf)
text = model.processor.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
)
inputs = model.processor(
text=[text],
images=[image],
padding=True,
return_tensors="pt",
min_pixels=128 * 28 * 28,
max_pixels=1280 * 28 * 28, # Force 1 MP cap
)
inputs.pop("token_type_ids", None)
inputs = inputs.to(model.device)
eos_id = model.processor.tokenizer.convert_tokens_to_ids("<|im_end|>")
if eos_id is None or isinstance(eos_id, list):
eos_id = model.processor.tokenizer.eos_token_id
pad_id = model.processor.tokenizer.pad_token_id if model.processor.tokenizer.pad_token_id is not None else eos_id
with devices.llm_context():
generated_ids = model.generate(
**inputs,
max_new_tokens=2048,
do_sample=False, # Deterministic decoding keeps bbox integer tokens strict
temperature=None, # Forces argmax token selection
repetition_penalty=1.01, # Breaks coordinate repetition loops without distorting valid coordinates
no_repeat_ngram_size=0, # MUST be 0/None—setting this > 0 corrupts valid repeated bbox coordinates
eos_token_id=eos_id, # Prevent premature EOS token stopping
pad_token_id=pad_id, # Prevent premature EOS token stopping
)
prompt_len = inputs["input_ids"].shape[1]
output_tokens = generated_ids[0][prompt_len:]
output_text = model.processor.tokenizer.decode(output_tokens, skip_special_tokens=True)
t1 = time.time()
response, results = parse(output_text, image)
log.debug(f'Detailer: name="{name}" tokens={output_tokens.shape[0]} response="{response}" items={len(results)} time={t1-t0:.3f}')
sd_offload_aux.offload_aux(name)
return results
+7
View File
@@ -91,3 +91,10 @@ def offload_aux(name: str) -> None:
if hasattr(entry.model, 'device') and devices.same_device(entry.model.device, devices.cpu):
return
_do_move_to_cpu(entry.model, f'post:{name}', entry.size)
def get_aux_model(name: str) -> torch.nn.Module | None:
entry = aux_models.get(name)
if entry is None:
return None
return entry.model