diff --git a/ai.memory/project_review_agent_impl.md b/ai.memory/project_review_agent_impl.md index 12bd6dc..fe29557 100644 --- a/ai.memory/project_review_agent_impl.md +++ b/ai.memory/project_review_agent_impl.md @@ -43,8 +43,11 @@ type: project ## 环境事实 -- 本机 **未安装 Pillow** → 截图下采样为**可选**(缺失时原样传原图,视觉 token 成本更高)。 - 如需控成本:`pip install pillow`。 +- **Pillow 已安装**(12.3.0)。截图下采样策略(`dwg_review/llm_client.py: downsample_image_b64`): + 视觉 token 成本由**像素尺寸**决定而非文件大小 → **只在长边超 `IMAGE_MAX_EDGE`(默认 1280)时才缩放**; + 既不需要缩放、格式又没变时**原样返回不重编码**(避免给 CAD 线稿引入无谓压缩损失); + 缩放后默认输出**无损 PNG**(`IMAGE_FORMAT=png`,适合线稿),需要更小体积可设 `IMAGE_FORMAT=jpeg`。 + 缺少 Pillow 时自动降级为原图直传。 - 视觉模型 `deepseek-flash`。 - **`DEEPSEEK_API_KEY` 设在 Windows `Machine`(系统)级**。因新设的系统变量**不会传播到已启动 的进程**,`dwg_review/config.py` 的 `_read_env()` 在进程环境读不到时**回退读注册表** diff --git a/dwg_review/config.py b/dwg_review/config.py index c1532c9..6558eef 100644 --- a/dwg_review/config.py +++ b/dwg_review/config.py @@ -90,9 +90,11 @@ ANNOTATE_LAYER = os.environ.get("ANNOTATE_LAYER", "AI_REVIEW") # --------------------------------------------------------------------------- # 截图(视觉 token 成本控制) # --------------------------------------------------------------------------- -# 长边上限(下采样目标),0 表示不下采样 +# 长边上限(超过才缩放),0 表示不缩放 IMAGE_MAX_EDGE = int(os.environ.get("IMAGE_MAX_EDGE", "1280")) -# JPEG 质量 +# 缩放后的输出格式:png(默认,无损,适合 CAD 线稿)或 jpeg(体积更小) +IMAGE_FORMAT = os.environ.get("IMAGE_FORMAT", "png").strip().lower() +# JPEG 质量(仅 IMAGE_FORMAT=jpeg 时生效) IMAGE_JPEG_QUALITY = int(os.environ.get("IMAGE_JPEG_QUALITY", "80")) diff --git a/dwg_review/llm_client.py b/dwg_review/llm_client.py index 075cf89..7505aa0 100644 --- a/dwg_review/llm_client.py +++ b/dwg_review/llm_client.py @@ -23,28 +23,46 @@ class LlmError(Exception): pass -def downsample_image_b64(b64, mime="image/png", max_edge=None, quality=None): - """把 base64 截图下采样并转 JPEG,降低视觉 token 成本。 +def downsample_image_b64(b64, mime="image/png", max_edge=None, fmt=None, quality=None): + """缩放截图,降低视觉 token 成本。 - 缺少 Pillow 或参数为 0 时原样返回 (b64, mime)。 + 策略(视觉 token 成本主要由**像素尺寸**决定,而非文件大小): + - 仅当长边超过 `max_edge` 时才缩放; + - 既不需要缩放、目标格式又与原格式一致时,**原样返回**(不重新编码, + 避免给 CAD 线稿引入无谓的压缩损失); + - 需要缩放时按 `fmt` 编码:`png`(默认,无损,适合线稿)/ `jpeg`(体积更小)。 + + 缺少 Pillow 或 max_edge <= 0 时原样返回 (b64, mime)。 """ max_edge = config.IMAGE_MAX_EDGE if max_edge is None else max_edge + fmt = (config.IMAGE_FORMAT if fmt is None else fmt).strip().lower() quality = config.IMAGE_JPEG_QUALITY if quality is None else quality if not HAS_PIL or max_edge <= 0: return b64, mime + + target_mime = "image/jpeg" if fmt == "jpeg" else "image/png" try: img = Image.open(io.BytesIO(base64.b64decode(b64))) - if img.mode not in ("RGB", "L"): - img = img.convert("RGB") w, h = img.size scale = max_edge / float(max(w, h)) - if scale < 1.0: + need_resize = scale < 1.0 + + if not need_resize and mime == target_mime: + return b64, mime + + if need_resize: img = img.resize( (max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS ) + buf = io.BytesIO() - img.save(buf, format="JPEG", quality=quality) - return base64.b64encode(buf.getvalue()).decode("ascii"), "image/jpeg" + if target_mime == "image/jpeg": + if img.mode not in ("RGB", "L"): + img = img.convert("RGB") + img.save(buf, format="JPEG", quality=quality) + else: + img.save(buf, format="PNG", optimize=True) + return base64.b64encode(buf.getvalue()).decode("ascii"), target_mime except Exception: return b64, mime