2026-06-26 13:30:18 +08:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""铸渊之眼 · 通义千问视觉分析器
|
|
|
|
|
|
用阿里百炼 qwen-vl 模型看图片,输出风格/色调/构图分析
|
|
|
|
|
|
|
2026-06-26 13:54:31 +08:00
|
|
|
|
⚠️ 密钥不在代码仓库里。读取规则见 LOCAL-SECRETS-PATH.hdlp。
|
|
|
|
|
|
密钥变量: ALIYUN_QWEN_VL_KEY + ALIYUN_QWEN_VL_ENDPOINT
|
|
|
|
|
|
读取顺序:
|
|
|
|
|
|
1. /Users/bingshuolingdianyuanhe/Documents/guanghulab-local-secrets/video-ai-system.env
|
|
|
|
|
|
2. ~/guanghulab/video-ai-system/.env
|
|
|
|
|
|
3. 当前进程环境变量
|
|
|
|
|
|
|
2026-06-26 13:30:18 +08:00
|
|
|
|
用法:
|
|
|
|
|
|
python3 qwen-vision.py <image.jpg> # 单图分析
|
|
|
|
|
|
python3 qwen-vision.py <image1.jpg> <image2.jpg> # 双图对比
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import sys, os, json, base64
|
|
|
|
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
|
from urllib.error import URLError
|
|
|
|
|
|
|
2026-06-26 13:54:31 +08:00
|
|
|
|
# === 密钥加载 ===
|
|
|
|
|
|
# 按 LOCAL-SECRETS-PATH.hdlp 规定的顺序加载
|
|
|
|
|
|
SECRET_PATHS = [
|
|
|
|
|
|
"/Users/bingshuolingdianyuanhe/Documents/guanghulab-local-secrets/video-ai-system.env",
|
|
|
|
|
|
os.path.expanduser("~/guanghulab/video-ai-system/.env"),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-06-26 13:30:18 +08:00
|
|
|
|
api_key = None
|
2026-06-26 13:54:31 +08:00
|
|
|
|
endpoint = None
|
|
|
|
|
|
|
|
|
|
|
|
# 先试环境变量
|
|
|
|
|
|
api_key = os.environ.get("ALIYUN_QWEN_VL_KEY") or os.environ.get("ALIYUN_BAILIAN_API_KEY")
|
|
|
|
|
|
endpoint = os.environ.get("ALIYUN_QWEN_VL_ENDPOINT") or "https://ws-umd6xwlovzmshuat.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
|
|
|
|
|
|
|
|
|
|
|
|
# 再从文件读
|
|
|
|
|
|
if not api_key:
|
|
|
|
|
|
for secret_path in SECRET_PATHS:
|
|
|
|
|
|
if os.path.exists(secret_path):
|
|
|
|
|
|
for line in open(secret_path):
|
|
|
|
|
|
line = line.strip()
|
|
|
|
|
|
if not line or line.startswith("#"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if "=" in line:
|
|
|
|
|
|
k, v = line.split("=", 1)
|
|
|
|
|
|
k = k.strip()
|
|
|
|
|
|
v = v.strip()
|
|
|
|
|
|
if k in ("ALIYUN_QWEN_VL_KEY", "ALIYUN_API_KEY", "ALIYUN_BAILIAN_API_KEY") and v and not api_key:
|
|
|
|
|
|
api_key = v
|
|
|
|
|
|
if k == "ALIYUN_QWEN_VL_ENDPOINT" and v and not os.environ.get("ALIYUN_QWEN_VL_ENDPOINT"):
|
|
|
|
|
|
endpoint = v
|
|
|
|
|
|
if api_key:
|
|
|
|
|
|
break
|
2026-06-26 13:30:18 +08:00
|
|
|
|
|
|
|
|
|
|
if not api_key:
|
2026-06-26 13:54:31 +08:00
|
|
|
|
print(json.dumps({"error": "未找到ALIYUN_QWEN_VL_KEY。请确认密钥文件存在。路径: LOCAL-SECRETS-PATH.hdlp"}))
|
2026-06-26 13:30:18 +08:00
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
2026-06-26 13:54:31 +08:00
|
|
|
|
# 端点
|
2026-06-26 13:30:18 +08:00
|
|
|
|
ENDPOINTS = [
|
2026-06-26 13:54:31 +08:00
|
|
|
|
endpoint,
|
2026-06-26 13:30:18 +08:00
|
|
|
|
"https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
|
|
|
|
|
|
]
|
|
|
|
|
|
MODELS = ["qwen-vl-max", "qwen3-vl-plus", "qwen-vl-plus"]
|
|
|
|
|
|
|
|
|
|
|
|
def encode_image(path):
|
|
|
|
|
|
"""读取图片并转为base64 data URI"""
|
|
|
|
|
|
with open(path, "rb") as f:
|
|
|
|
|
|
b64 = base64.b64encode(f.read()).decode()
|
|
|
|
|
|
ext = path.rsplit(".", 1)[-1].lower()
|
|
|
|
|
|
mime = {"jpg": "jpeg", "jpeg": "jpeg", "png": "png", "webp": "webp"}.get(ext, "jpeg")
|
|
|
|
|
|
return f"data:image/{mime};base64,{b64}"
|
|
|
|
|
|
|
|
|
|
|
|
def call_vision(images, prompt, model, endpoint):
|
|
|
|
|
|
"""调用视觉模型"""
|
|
|
|
|
|
content = []
|
|
|
|
|
|
for img in images:
|
|
|
|
|
|
content.append({"image": img})
|
|
|
|
|
|
content.append({"text": prompt})
|
|
|
|
|
|
|
|
|
|
|
|
body = {
|
|
|
|
|
|
"model": model,
|
|
|
|
|
|
"input": {"messages": [{"role": "user", "content": content}]}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
req = Request(
|
|
|
|
|
|
endpoint,
|
|
|
|
|
|
data=json.dumps(body).encode(),
|
|
|
|
|
|
headers={
|
|
|
|
|
|
"Authorization": f"Bearer {api_key}",
|
|
|
|
|
|
"Content-Type": "application/json"
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
resp = urlopen(req, timeout=60)
|
|
|
|
|
|
return json.loads(resp.read())
|
|
|
|
|
|
|
|
|
|
|
|
def extract_content(response):
|
|
|
|
|
|
"""从响应中提取文本内容"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
return response["output"]["choices"][0]["message"]["content"][0]["text"]
|
|
|
|
|
|
except:
|
|
|
|
|
|
return json.dumps(response, ensure_ascii=False)
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
if len(sys.argv) < 2:
|
|
|
|
|
|
print("用法: qwen-vision.py <image> [image2]")
|
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
images = [encode_image(p) for p in sys.argv[1:]]
|
|
|
|
|
|
|
|
|
|
|
|
if len(images) == 1:
|
|
|
|
|
|
prompt = """请详细分析这张图片的视觉特征,输出JSON格式:
|
|
|
|
|
|
{
|
|
|
|
|
|
"style": "渲染风格(如3D动漫/2D手绘/真人写实/UE5游戏等)",
|
|
|
|
|
|
"color_palette": ["主色调1", "主色调2", "主色调3"],
|
|
|
|
|
|
"lighting": "光影风格描述",
|
|
|
|
|
|
"composition": "构图方式(特写/中景/全景/俯视/平视等)",
|
|
|
|
|
|
"key_elements": ["画面中的关键元素"],
|
|
|
|
|
|
"text_content": "画面中出现的所有文字内容",
|
|
|
|
|
|
"mood": "氛围感受"
|
|
|
|
|
|
}
|
|
|
|
|
|
只输出JSON,不要其他文字。"""
|
|
|
|
|
|
else:
|
|
|
|
|
|
prompt = """请对比这两张图片,输出JSON格式:
|
|
|
|
|
|
{
|
|
|
|
|
|
"style_match": true或false,
|
|
|
|
|
|
"style_match_detail": "两张图渲染风格是否一致的具体说明",
|
|
|
|
|
|
"color_consistency": "色调是否一致,给出0-100分",
|
|
|
|
|
|
"composition_match": "构图方式是否协调",
|
|
|
|
|
|
"key_differences": ["主要差异点"],
|
|
|
|
|
|
"recommendation": "如果要让第二张图匹配第一张图的风格,建议修改什么"
|
|
|
|
|
|
}
|
|
|
|
|
|
只输出JSON,不要其他文字。"""
|
|
|
|
|
|
|
|
|
|
|
|
# 尝试不同模型和端点
|
|
|
|
|
|
result = None
|
|
|
|
|
|
for model in MODELS:
|
|
|
|
|
|
for ep in ENDPOINTS:
|
|
|
|
|
|
try:
|
|
|
|
|
|
print(f"[尝试] {model} @ {ep[:50]}...", file=sys.stderr)
|
|
|
|
|
|
resp = call_vision(images, prompt, model, ep)
|
|
|
|
|
|
content = extract_content(resp)
|
|
|
|
|
|
# 尝试解析JSON
|
|
|
|
|
|
try:
|
|
|
|
|
|
# 提取JSON(可能被markdown包裹)
|
|
|
|
|
|
if "```json" in content:
|
|
|
|
|
|
content = content.split("```json")[1].split("```")[0]
|
|
|
|
|
|
elif "```" in content:
|
|
|
|
|
|
content = content.split("```")[1].split("```")[0]
|
|
|
|
|
|
parsed = json.loads(content.strip())
|
|
|
|
|
|
parsed["_model"] = model
|
|
|
|
|
|
parsed["_endpoint"] = ep
|
|
|
|
|
|
print(json.dumps(parsed, ensure_ascii=False, indent=2))
|
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
|
print(content)
|
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
except URLError as e:
|
|
|
|
|
|
print(f"[失败] {model}: {e}", file=sys.stderr)
|
|
|
|
|
|
continue
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f"[异常] {model}: {e}", file=sys.stderr)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
print(json.dumps({"error": "所有模型/端点都失败了"}, ensure_ascii=False))
|
|
|
|
|
|
sys.exit(1)
|