read_images.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. """
  2. Read Images Tool - 批量读取图片工具
  3. 为"批量读取 + 多图分析"场景设计的工具,与单文件的 read_file 分工:
  4. - read_file: 单个文件(文本 / PDF / 单张图片)
  5. - read_images: 2 张以上图片,支持网格拼图和降采样
  6. 核心能力:
  7. 1. 并发批量加载本地路径或 URL
  8. 2. 自动降采样,防止 token 爆炸
  9. 3. 可选拼图(grid 模式),把 N 张图合成一张带索引编号的网格图,
  10. 适合 LLM 横向对比、选图、批量判断场景
  11. 4. 自适应布局 + 硬上限,保证拼图即使经过 LLM 内部缩放也能保持可辨
  12. """
  13. from typing import Any, Dict, List, Literal, Optional, Tuple
  14. from agent.tools import tool, ToolResult, ToolContext
  15. from agent.tools.utils.image import (
  16. build_image_grid,
  17. downscale,
  18. encode_base64,
  19. load_images,
  20. )
  21. # Grid 模式的硬上限:超过此数量必须分批调用
  22. # 理由:12 张可排成 4x3 网格,每格 ~320px,人物/场景细节清晰可辨。
  23. # 再多格子就太小,分辨不出内容,失去对比价值。
  24. MAX_GRID_IMAGES = 12
  25. def _adaptive_layout(count: int) -> Tuple[int, int]:
  26. """根据图片数量自动选择 (columns, thumb_size)。
  27. 目标:拼图最终边长不超过 ~1400px,同时每格缩略图保持 >= 320px 以保证可辨认。
  28. Returns:
  29. (columns, thumb_size)
  30. """
  31. if count <= 2:
  32. return 2, 500 # 2x1
  33. if count <= 4:
  34. return 2, 450 # 2x2
  35. if count <= 6:
  36. return 3, 400 # 3x2
  37. if count <= 9:
  38. return 3, 380 # 3x3
  39. # 10-12
  40. return 4, 320 # 4x3
  41. @tool(
  42. description="批量读取多张图片,支持自动降采样和网格拼图(用于横向对比/选图场景)",
  43. hidden_params=["context"],
  44. display={
  45. "zh": {
  46. "name": "批量读取图片",
  47. "params": {
  48. "paths": "图片路径列表",
  49. "layout": "布局模式",
  50. "max_dimension": "每张图最大边长",
  51. },
  52. },
  53. "en": {
  54. "name": "Read Images",
  55. "params": {
  56. "paths": "Image paths",
  57. "layout": "Layout mode",
  58. "max_dimension": "Max dimension per image",
  59. },
  60. },
  61. },
  62. groups=["core"],
  63. )
  64. async def read_images(
  65. paths: List[str],
  66. layout: Literal["grid", "separate"] = "grid",
  67. max_dimension: int = 1024,
  68. context: Optional[ToolContext] = None,
  69. ) -> ToolResult:
  70. """批量读取图片并返回给 LLM,支持自动降采样和网格拼图
  71. 为 **2 张以上** 的图片批量分析场景设计。单张图片请用 `read_file`。
  72. ⚠️ **grid 模式最多 12 张**。超过请分批调用:第一次传前 12 张,第二次传后续,
  73. 以此类推。再多每格就太小,分辨不出内容。
  74. 两种布局模式:
  75. - **grid**(默认):把所有图片拼成一张只带索引编号的网格图(1,2,3…)。
  76. LLM 只看到 1 张拼图,大幅减少结构开销 token。索引对应的原始路径见
  77. 返回文本的对照表,LLM 可以用"第 3 张"来引用具体图片。
  78. **自适应布局**:根据图片数量自动选择列数和缩略图尺寸,小批量时每张图更清晰:
  79. * 1-2 张:2 列 × 500px
  80. * 3-4 张:2 列 × 450px
  81. * 5-6 张:3 列 × 400px
  82. * 7-9 张:3 列 × 380px
  83. * 10-12 张:4 列 × 320px
  84. 适合:从多张候选图中挑选、横向对比质量/风格、批量判断。
  85. - **separate**:把每张图独立返回(仍然降采样)。无数量限制,但每张图都有
  86. 独立的结构开销 token。适合:
  87. * 需要逐张做独立的精细分析
  88. * 每张图之间没有对比关系
  89. 自动降采样:无论哪种模式,每张图都会先降采样到 max_dimension(默认 1024px)
  90. 的最大边长,防止高分辨率图片炸掉 token 预算。
  91. Args:
  92. paths: 图片路径列表,支持本地路径和 HTTP(S) URL,可混用。
  93. grid 模式下不超过 12 张,超过必须分批调用。
  94. layout: 布局模式,"grid" 拼图(默认)/ "separate" 多张独立
  95. max_dimension: 每张图的最大边长(等比降采样到不超过此值),默认 1024
  96. context: 工具上下文(框架注入,无需手动传)
  97. Returns:
  98. ToolResult:images 字段包含图片数据(grid 模式 1 张拼图,separate 模式 N 张),
  99. output 字段包含每张图的索引和来源路径对照表
  100. """
  101. if not paths:
  102. return ToolResult(
  103. title="批量读图失败",
  104. output="",
  105. error="paths 不能为空",
  106. )
  107. # 硬上限检查(仅对 grid 模式)
  108. if layout == "grid" and len(paths) > MAX_GRID_IMAGES:
  109. return ToolResult(
  110. title="批量读图失败",
  111. output="",
  112. error=(
  113. f"grid 模式最多支持 {MAX_GRID_IMAGES} 张图片,当前传入 {len(paths)} 张。"
  114. f"请分批调用:每次最多 {MAX_GRID_IMAGES} 张。"
  115. f"或者使用 layout='separate' 模式(无数量限制但 token 开销更高)。"
  116. ),
  117. )
  118. if len(paths) == 1:
  119. hint = "(只有 1 张图片,建议用 read_file 更合适)"
  120. else:
  121. hint = ""
  122. # 1. 并发加载所有图片
  123. loaded = await load_images(paths)
  124. # 2. 分离成功和失败
  125. successes: List[tuple] = [] # [(path, PIL.Image), ...]
  126. failures: List[str] = [] # [path, ...]
  127. for source, img in loaded:
  128. if img is None:
  129. failures.append(source)
  130. else:
  131. successes.append((source, img))
  132. if not successes:
  133. return ToolResult(
  134. title="批量读图失败",
  135. output="",
  136. error=f"所有 {len(paths)} 张图片均加载失败",
  137. metadata={"failed": failures},
  138. )
  139. # 3. 每张图降采样
  140. processed = [(src, downscale(img, max_dimension)) for src, img in successes]
  141. # 4. 构建索引 → 路径对照表(用完整路径,方便 LLM 后续引用或调用)
  142. index_lines = [f"{i}. {src}" for i, (src, _) in enumerate(processed, start=1)]
  143. summary_parts = [f"共加载 {len(processed)}/{len(paths)} 张图片"]
  144. if hint:
  145. summary_parts.append(hint)
  146. if failures:
  147. summary_parts.append(f",失败 {len(failures)} 张")
  148. summary = "".join(summary_parts)
  149. output_lines = [summary, ""] + index_lines
  150. if failures:
  151. output_lines.append("")
  152. output_lines.append("加载失败的路径:")
  153. output_lines.extend(f" - {p}" for p in failures)
  154. output_text = "\n".join(output_lines)
  155. # 5. 根据 layout 生成 images 字段
  156. images_for_llm = []
  157. if layout == "grid":
  158. cols, thumb_size = _adaptive_layout(len(processed))
  159. # 网格只显示序号,不写文件名 —— 索引对应的路径见上方 output 文本
  160. grid = build_image_grid(
  161. images=[img for _, img in processed],
  162. labels=None,
  163. columns=cols,
  164. thumb_size=thumb_size,
  165. )
  166. # 网格拼图固定用 JPEG 节省 token
  167. b64, media_type = encode_base64(grid, format="JPEG", quality=80)
  168. images_for_llm.append({
  169. "type": "base64",
  170. "media_type": media_type,
  171. "data": b64,
  172. })
  173. else: # separate
  174. for _, img in processed:
  175. b64, media_type = encode_base64(img, format="JPEG", quality=80)
  176. images_for_llm.append({
  177. "type": "base64",
  178. "media_type": media_type,
  179. "data": b64,
  180. })
  181. return ToolResult(
  182. title=f"批量读图成功({layout} 模式,{len(processed)} 张)",
  183. output=output_text,
  184. long_term_memory=f"Read {len(processed)} images via {layout} layout",
  185. images=images_for_llm,
  186. metadata={
  187. "count": len(processed),
  188. "failed_count": len(failures),
  189. "layout": layout,
  190. },
  191. )
  192. # ── CLI 入口:图片拼图工具 ──
  193. #
  194. # 这个 CLI 的语义是**拼图工具**,不是"读图工具"——Claude Code 这样的调用方
  195. # 本身就能读单张图(用 Read 工具),真正稀缺的能力是把 N 张图合成一张
  196. # 带索引编号的网格图,让一次 Read 就能横向对比多张。
  197. #
  198. # 因此 CLI 只支持 grid 模式;如果你需要单张图,直接用 Read 工具即可。
  199. #
  200. # 用法:
  201. # python agent/tools/builtin/file/read_images.py --out=<path> <img1> <img2> ...
  202. #
  203. # 必填参数:
  204. # --out=/path/grid.jpg 拼图保存路径(必须显式指定,避免污染 /tmp)
  205. #
  206. # 可选参数:
  207. # --max_dimension=1024 每张图预先降采样的最大边长(默认 1024)
  208. #
  209. # 示例:
  210. # python agent/tools/builtin/file/read_images.py \
  211. # --out=/tmp/compare.jpg \
  212. # ~/Downloads/a.jpg ~/Downloads/b.jpg ~/Downloads/c.jpg
  213. #
  214. # 输出:一行 JSON,包含 out_path、index_map(索引→原始路径对照表)、
  215. # text(文字摘要)。调用方拿到 out_path 后用 Read 工具查看拼图即可。
  216. if __name__ == "__main__":
  217. import base64
  218. import json
  219. import sys
  220. from pathlib import Path as _Path
  221. def _print_usage():
  222. print("用法: python read_images.py --out=<path> <img1> <img2> ...")
  223. print(" --out=/path/grid.jpg 拼图输出路径(必填)")
  224. print(" --max_dimension=1024 每张图最大边长(可选,默认 1024)")
  225. print(f"最多 {MAX_GRID_IMAGES} 张图片")
  226. if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
  227. _print_usage()
  228. sys.exit(0)
  229. # 解析参数
  230. cli_paths: List[str] = []
  231. cli_out: Optional[str] = None
  232. cli_max_dim: int = 1024
  233. for arg in sys.argv[1:]:
  234. if arg.startswith("--") and "=" in arg:
  235. k, v = arg.split("=", 1)
  236. k = k.lstrip("-").replace("-", "_")
  237. if k == "out":
  238. cli_out = v
  239. elif k == "max_dimension":
  240. cli_max_dim = int(v)
  241. else:
  242. print(f"警告: 未知参数 {k}", file=sys.stderr)
  243. else:
  244. cli_paths.append(arg)
  245. if not cli_paths:
  246. print("错误: 至少提供一个图片路径", file=sys.stderr)
  247. _print_usage()
  248. sys.exit(1)
  249. if not cli_out:
  250. print("错误: 必须显式指定 --out=<path>", file=sys.stderr)
  251. _print_usage()
  252. sys.exit(1)
  253. import asyncio
  254. result = asyncio.run(read_images(
  255. paths=cli_paths,
  256. layout="grid",
  257. max_dimension=cli_max_dim,
  258. ))
  259. if result.error:
  260. print(json.dumps({"error": result.error}, ensure_ascii=False, indent=2))
  261. sys.exit(1)
  262. # 写入拼图文件
  263. out_p = _Path(cli_out)
  264. out_p.parent.mkdir(parents=True, exist_ok=True)
  265. out_p.write_bytes(base64.b64decode(result.images[0]["data"]))
  266. # 解析索引 → 原始路径对照表
  267. index_map: List[Dict[str, Any]] = []
  268. for line in result.output.split("\n"):
  269. if line and line[0].isdigit() and ". " in line:
  270. idx_str, src = line.split(". ", 1)
  271. if idx_str.isdigit():
  272. index_map.append({"index": int(idx_str), "source": src})
  273. print(json.dumps({
  274. "out_path": str(out_p.resolve()),
  275. "count": result.metadata.get("count", 0) if result.metadata else 0,
  276. "index_map": index_map,
  277. "text": result.output,
  278. }, ensure_ascii=False, indent=2))