File size: 12,537 Bytes
53ccd32 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 | # -*- coding: utf-8 -*-
"""
使用方法:
python rule_11_vllm.py \
--input_dir "/path/to/your/images" \
--model_path "/path/to/your/Qwen2.5-VL-7B-Instruct"
"""
import os
import re
import json
import argparse
import multiprocessing
from pathlib import Path
from typing import Dict, List, Optional, Any
from tqdm import tqdm
from PIL import Image, ImageFile
from transformers import AutoProcessor
from vllm import LLM, SamplingParams
os.environ['VLLM_WORKER_MULTIPROC_METHOD'] = 'spawn'
ImageFile.LOAD_TRUNCATED_IMAGES = True
IMG_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff"}
SYS_PROMPT_TEXT ="""You are a highly critical Senior Art Director and Visual Auditor.
Your task is to evaluate "Text Visual Weight & Layout Balance" to prevent visual overcrowding while allowing for artistic typographic choices.
INPUT: One image and one natural-language question about text density or layout balance.
YOUR TASK:
1. Analyze the visual weight of the text relative to the canvas (Area coverage + Visual heaviness).
2. Apply the "Aesthetic Filter": Distinguish between "Cheap Da Zi Bao" (Violation) and "High-End Artistic Text" (Safe).
3. Determine if the image is a **VIOLATION** (Unsuitable) or **SAFE** (Suitable).
4. Output a JSON object containing a rigorous Chain-of-Thought ("think") and a precise classification label ("answer").
OUTPUT FORMAT:
Return EXACTLY two blocks, no extra text:
<think>Detailed reasoning steps: 1. Estimate text area coverage -> 2. Assess design quality (Suffocating vs. Artistic) -> 3. Check for product obstruction...</think><answer>{"Answer": "<Suitable OR Unsuitable>", "Answer type": "Text Visual Weight"}</answer>
=========================================
CORE PRINCIPLE: BALANCE VS. SUFFOCATION
=========================================
- **The Rule:** Marketing text should generally occupy < 25% of the visual weight.
- **The Exception:** Large text IS allowed if it is "Concise, Exquisite, and High-End" (Magazine Style).
- **The Prohibition:** Large text is FORBIDDEN if it is "Crowded, Aggressive, and Cheap" (Da Zi Bao Style).
- **Maximum Text Density:** Regardless of artistic quality, any image containing more than 6 lines of narrative text or 50 words is automatically a VIOLATION (Information Overload).
- **Literal Line Counting:** Each line in a bulleted list or paragraph counts as 1 line. A neatly organized list of 10 lines is still a VIOLATION of the 6-line limit.
=========================================
STRICT DECISION HIERARCHY (FOLLOW IN ORDER)
=========================================
1. HARD LIMIT CHECK:
- Does the image have > 6 lines of text total (including text inside phone/UI)?
- If YES -> Label: UNSUITABLE (Reason: Text Density Overload).
- Zero UI Exemption: Text inside phone screens or UI mockups is NOT background decoration; it is active text weight. If the phone screen is filled with more than 4-5 lines of content, the entire image is likely UNSUITABLE.
2. VISUAL WEIGHT CHECK:
- Does the text (and its background boxes/screens) occupy more than 30% of the canvas?
- If YES -> Label: UNSUITABLE (Reason: Excessive Visual Weight).
3. AESTHETIC FILTER (The "Premium" Test):
- Is it "Artistic Exception"? ONLY if text is < 3 lines AND elegantly integrated.
- Note: A phone screen filled with tiny text is NEVER "Artistic" or "High-End" in an ad context; it is a "Manual Page" (UNSUITABLE).
=========================================
CRITERIA FOR 'UNSUITABLE' (VIOLATION / OVERWHELMING)
=========================================
1. **Aggressive "Da Zi Bao" (大字报) Style:**
- **Visual Suffocation:** Massive, bold text occupies the central area with zero "breathing room" (negative space).
- **Cheap Aesthetic:** It looks like a spam flyer or a shouting warning sign rather than a professional ad.
- **Shouting Effect:** The font size is absurdly large relative to the canvas without any artistic justification.
2. **Visual Obstruction & Imbalance:**
- **Blocking the Hero:** Text covers the main product, model's face, or key visual storytelling elements.
- **Excessive Weight:** The text area visually dominates > 30-40% of the canvas in a messy, cluttered way.
3.**The "Manual/Article" Trap:**
- Images that look like an instruction manual page, a reading app screenshot, or a news article are automatically UNSUITABLE. Ads must remain "Visual-First," not "Text-First."
=========================================
CRITERIA FOR 'SUITABLE' (SAFE / BALANCED)
=========================================
1. **Standard Good Ratio:**
- **Balanced:** Text occupies a reasonable area (roughly < 25% of visual weight).
- **Clear Hierarchy:** The Product/Illustration is the HERO; the Text is the SUPPORT.
2. **The "Artistic Exception" (High-End Large Text):**
- **Premium Look:** Even if the headline is large, it is concise, elegant, and integrated well with the background.
- **Breathing Room:** The layout maintains generous margins and negative space. It feels like a Vogue cover or a movie poster, not a supermarket discount flyer.
=========================================
DECISION LOGIC
=========================================
- **Unsuitable**: If the text creates a "suffocating" effect, blocks the product, or looks like a cheap, crowded "Da Zi Bao".
- **Suitable**: If the text is minimal (<25%), OR if it is large but designed with high artistic quality and ample negative space.
"""
def collect_images(input_dir: Path) -> List[Dict[str, str]]:
if not input_dir.exists():
raise FileNotFoundError(f"Input directory not found: {input_dir}")
files = [p for p in input_dir.iterdir() if p.is_file() and p.suffix.lower() in IMG_EXTS]
files.sort()
print(f"[Info] Found {len(files)} images in {input_dir}")
return [{"path": str(p), "filename": p.name} for p in files]
def parse_llm_output(text: str) -> Dict[str, Any]:
default_res = {
"label": "Parse Error",
"think": "No reasoning found",
"raw": text
}
if not text:
return default_res
think_match = re.search(r'<think>(.*?)</think>', text, re.DOTALL)
think_content = think_match.group(1).strip() if think_match else ""
answer_match = re.search(r'<answer>(.*?)</answer>', text, re.DOTALL)
extracted_label = "Parse Error"
if answer_match:
json_str = answer_match.group(1).strip()
try:
data = json.loads(json_str)
raw_ans = data.get("Answer", "")
if "unsuitable" in raw_ans.lower():
extracted_label = "Unsuitable"
elif "suitable" in raw_ans.lower():
extracted_label = "Suitable"
else:
extracted_label = raw_ans
except json.JSONDecodeError:
if "Unsuitable" in json_str:
extracted_label = "Unsuitable"
elif "Suitable" in json_str:
extracted_label = "Suitable"
else:
if "Unsuitable" in text:
extracted_label = "Unsuitable"
elif "Suitable" in text:
extracted_label = "Suitable"
return {
"label": extracted_label,
"think": think_content,
"raw": text
}
def prepare_vllm_inputs(batch_meta: List[Dict], processor) -> List[Dict]:
vllm_inputs = []
user_query = "Analyze this image against the design rules and return the JSON decision."
for item in batch_meta:
img_path = item["path"]
try:
image_obj = Image.open(img_path).convert("RGB")
messages = [
{"role": "system", "content": [{"type": "text", "text": SYS_PROMPT_TEXT}]},
{"role": "user", "content": [
{"type": "image", "image": img_path},
{"type": "text", "text": user_query}
]}
]
prompt_text = processor.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
vllm_inputs.append({
"prompt": prompt_text,
"multi_modal_data": {"image": image_obj}
})
except Exception as e:
print(f"[Warning] Failed to load {img_path}: {e}")
vllm_inputs.append(None)
return vllm_inputs
def main():
parser = argparse.ArgumentParser(description="AI Visual Comfort Auditor")
parser.add_argument("--input_dir", type=str, required=True, help="Folder containing images to check")
parser.add_argument("--model_path", type=str, required=True, help="Path to local Qwen-VL model")
parser.add_argument("--batch_size", type=int, default=512, help="Inference batch size")
parser.add_argument("--tp_size", type=int, default=2, help="Tensor Parallel size")
args = parser.parse_args()
input_path = Path(args.input_dir)
meta_data = collect_images(input_path)
if not meta_data:
print("[Info] No images found. Exiting.")
return
# ---------------------------
# 初始化模型
# ---------------------------
print(f"\n[Init] Loading Model: {args.model_path}")
llm = LLM(
model=args.model_path,
tokenizer=args.model_path,
trust_remote_code=True,
tensor_parallel_size=args.tp_size,
gpu_memory_utilization=0.90,
max_model_len=8192,
enforce_eager=True,
limit_mm_per_prompt={"image": 1}
)
processor = AutoProcessor.from_pretrained(args.model_path, trust_remote_code=True)
# 采样参数
sampling_params = SamplingParams(
temperature=0.7, # 稍微降低温度以获得更稳定的分类
max_tokens=1024,
top_p=0.9
)
# ---------------------------
# 批量推理
# ---------------------------
results = []
print(f"\n[Run] Starting Inference on {len(meta_data)} images...")
for i in tqdm(range(0, len(meta_data), args.batch_size), desc="Processing Batches"):
batch_meta = meta_data[i : i + args.batch_size]
batch_inputs = prepare_vllm_inputs(batch_meta, processor)
valid_inputs = [inp for inp in batch_inputs if inp is not None]
valid_indices = [idx for idx, inp in enumerate(batch_inputs) if inp is not None]
if not valid_inputs:
continue
outputs = llm.generate(valid_inputs, sampling_params=sampling_params, use_tqdm=False)
for local_idx, out in enumerate(outputs):
original_meta = batch_meta[valid_indices[local_idx]]
generated_text = out.outputs[0].text
# 解析结果
parsed = parse_llm_output(generated_text)
results.append({
"filename": original_meta["filename"],
"path": original_meta["path"],
"label": parsed["label"], # Suitable / Unsuitable
"think": parsed["think"], # 思维链
"raw_output": generated_text
})
# ---------------------------
# 统计与输出
# ---------------------------
total = len(results)
unsuitable_count = sum(1 for r in results if r["label"] == "Unsuitable")
suitable_count = sum(1 for r in results if r["label"] == "Suitable")
error_count = total - unsuitable_count - suitable_count
unsuitable_rate = (unsuitable_count / total * 100) if total > 0 else 0
suitable_rate = (suitable_count / total * 100) if total > 0 else 0
print("\n" + "="*60)
print(f"AUDIT REPORT FOR: {input_path.name}")
print("="*60)
print(f"{'Total Images':<25}: {total}")
print("-" * 60)
print(f"{'UNSUITABLE (Violation)':<25}: {unsuitable_count} ({unsuitable_rate:.2f}%)")
print(f"{'SUITABLE (Safe)':<25}: {suitable_count} ({suitable_rate:.2f}%)")
print(f"{'Parse Errors':<25}: {error_count}")
print("="*60)
# 保存结果
output_file = input_path / f"audit_result_{input_path.name}.json"
try:
with open(output_file, "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f"\n[Done] Detailed JSON report saved to:\n-> {output_file}")
except Exception as e:
print(f"[Error] Could not save JSON: {e}")
if __name__ == "__main__":
try:
multiprocessing.set_start_method('spawn', force=True)
except RuntimeError:
pass
main() |