agent-l / agents /cognitive_annotator.py
zhou777's picture
Add files using upload-large-folder tool
cf0614b verified
Raw
History Blame Contribute Delete
15.2 kB
from __future__ import annotations
import asyncio
import base64
import imghdr
import json
import logging
import os
from pathlib import Path
from typing import Any
import httpx
from openai import AsyncOpenAI
from tqdm.asyncio import tqdm
from base_agent import BaseAgent
from schemas.annotation import AnnotationResult
logger = logging.getLogger(__name__)
class CognitiveAnnotatorAgent(BaseAgent):
"""
基于 Qwen 兼容接口的高并发认知标注智能体。
当前版本面向“单图多目标”西红柿认知标注任务:
1. 模型返回图中全部尽可能清晰可判断的西红柿目标。
2. 模型侧输出的 bbox 视为 0 到 1000 的相对坐标。
3. 审核阶段负责将相对坐标换算为图像像素坐标。
4. 每个目标的成熟度、遮挡与推理说明都必须与对应 bbox 一一对齐。
"""
SYSTEM_PROMPT = "你是一个严格遵守要求并只返回合法 JSON 的助手。"
USER_PROMPT = """请对这张图像中的西红柿目标进行尽可能完整的结构化标注。
你必须识别图中所有你能清晰判断的西红柿目标,而不是只选一个主目标。
如果图中存在多个西红柿,请输出多个目标。
如果某些目标严重遮挡、极小、极模糊、无法可靠判断,可以跳过,但应优先保证“尽可能全”。
请先在心中完成“逐个目标定位”,再分别输出每个目标的 bbox 与属性;不要先写属性再随意补 bbox。
请只返回合法 JSON,不要输出 Markdown,不要输出代码块,不要输出额外解释文字。
输出格式必须为一个 JSON 对象,且只能包含一个 key:
- annotations: 一个数组,数组中每个元素对应一个西红柿目标
annotations 中每个目标必须包含以下字段:
1. bbox: [x_min, y_min, x_max, y_max]
2. maturity_level: 只能是“未成熟”/“半成熟”/“完熟”
3. maturity_ratio: 0.0 到 1.0 之间的浮点数
4. occlusion_degree: 只能是“无”/“轻度”/“重度”
5. reasoning: 字符串,说明你对该目标判断的依据
严格要求:
1. 每个 bbox 必须和对应的那个西红柿目标一一对应,不能张冠李戴。
2. reasoning、maturity_level、maturity_ratio、occlusion_degree 都必须只针对该 bbox 对应的目标本身,不要混入其他果实的信息。
3. maturity_ratio 必须和 maturity_level 保持一致:
- 未成熟:通常小于 0.3
- 半成熟:通常在 0.3 到 0.7 之间
- 完熟:通常大于 0.7
4. 如果一个目标无法可靠判断,可以不输出该目标,也不要编造。
5. bbox 默认按 0 到 1000 的归一化相对坐标输出。
6. 输出必须是可被 json.loads 直接解析的合法 JSON。"""
def __init__(
self,
agent_name: str = "cognitive_annotator",
config: dict[str, Any] | None = None,
) -> None:
"""初始化认知标注智能体。"""
super().__init__(agent_name=agent_name, config=config)
self.api_key = self.config.get("api_key") or os.getenv("QWEN_API_KEY") or os.getenv("OPENAI_API_KEY")
self.base_url = self.config.get("base_url") or os.getenv("QWEN_BASE_URL")
self.model = self.config.get("model", "qwen-vl-max")
self.max_concurrency = int(self.config.get("max_concurrency", 10))
self.max_retries = int(self.config.get("max_retries", 5))
self.request_timeout = float(self.config.get("request_timeout", 120))
self.output_path = Path(self.config.get("output_path", "raw_annotations.jsonl"))
if not self.api_key:
raise ValueError("缺少 API Key,请在 config['api_key'] 或环境变量中提供。")
if not self.base_url:
raise ValueError("缺少 base_url,请在 config['base_url'] 或环境变量中提供。")
self.client = AsyncOpenAI(
api_key=self.api_key,
base_url=self.base_url,
http_client=httpx.AsyncClient(),
)
self._semaphore = asyncio.Semaphore(self.max_concurrency)
self._file_lock = asyncio.Lock()
def run(self, image_path: str) -> dict[str, Any]:
"""同步处理单张图像。"""
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(self.annotate_single(image_path))
raise RuntimeError("当前已处于事件循环中,请使用 await annotate_single(image_path)。")
async def annotate_single(self, image_path: str) -> dict[str, Any]:
"""异步处理单张图像并返回多目标结构化结果。"""
image_file = Path(image_path)
annotations = await self._annotate_with_retry(image_file)
record = self._build_output_record(image_file=image_file, annotations=annotations)
await self._append_record(record)
return record
async def annotate_images_async(self, image_paths: list[str]) -> dict[str, int]:
"""并发处理多张图像并实时保存结果。"""
processed_paths = self._load_processed_paths()
pending_paths = [Path(path) for path in image_paths if str(Path(path).resolve()) not in processed_paths]
success_count = 0
failure_count = 0
skipped_count = len(image_paths) - len(pending_paths)
if not pending_paths:
return {
"total": len(image_paths),
"skipped": skipped_count,
"success": success_count,
"failure": failure_count,
}
progress_bar = tqdm(total=len(pending_paths), desc="Qwen 标注进度", unit="img")
tasks = [asyncio.create_task(self._process_image(image_file)) for image_file in pending_paths]
for future in asyncio.as_completed(tasks):
try:
is_success = await future
if is_success:
success_count += 1
else:
failure_count += 1
except Exception:
failure_count += 1
progress_bar.update(1)
progress_bar.set_postfix(
success=success_count,
failed=failure_count,
skipped=skipped_count,
)
progress_bar.close()
return {
"total": len(image_paths),
"skipped": skipped_count,
"success": success_count,
"failure": failure_count,
}
def annotate_images(self, image_paths: list[str]) -> dict[str, int]:
"""同步入口:批量处理图像。"""
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(self.annotate_images_async(image_paths))
raise RuntimeError("当前已处于事件循环中,请使用 await annotate_images_async(image_paths)。")
async def aclose(self) -> None:
"""关闭底层异步客户端,释放网络连接资源。"""
await self.client.close()
async def _process_image(self, image_file: Path) -> bool:
"""处理单张图片并捕获异常。"""
try:
annotations = await self._annotate_with_retry(image_file)
await self._append_record(self._build_output_record(image_file=image_file, annotations=annotations))
return True
except Exception:
logger.exception("处理图片失败: %s", image_file)
return False
async def _annotate_with_retry(self, image_file: Path) -> list[AnnotationResult]:
"""执行带指数退避的单图推理请求。"""
last_error: Exception | None = None
for attempt in range(1, self.max_retries + 1):
try:
async with self._semaphore:
return await self._call_qwen_api(image_file)
except Exception as exc:
last_error = exc
logger.warning(
"第 %s/%s 次调用失败,准备重试: %s, error=%s",
attempt,
self.max_retries,
image_file,
exc,
)
if attempt >= self.max_retries:
break
backoff_seconds = min(2 ** (attempt - 1), 16)
jitter = 0.1 * attempt
await asyncio.sleep(backoff_seconds + jitter)
raise RuntimeError(f"图像处理失败: {image_file}") from last_error
async def _call_qwen_api(self, image_file: Path) -> list[AnnotationResult]:
"""调用 Qwen 兼容多模态接口并解析结果。"""
image_data_url = self._build_image_data_url(image_file)
response = await self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": self.SYSTEM_PROMPT},
{
"role": "user",
"content": [
{"type": "text", "text": self.USER_PROMPT},
{"type": "image_url", "image_url": {"url": image_data_url}},
],
},
],
temperature=0.1,
response_format={"type": "json_object"},
timeout=self.request_timeout,
)
content = response.choices[0].message.content
if not content:
raise ValueError(f"模型未返回内容: {image_file}")
if isinstance(content, list):
text_chunks = [item.get("text", "") for item in content if isinstance(item, dict)]
content = "".join(text_chunks).strip()
payload = json.loads(content)
return self._validate_annotation_batch(payload)
def _build_output_record(self, image_file: Path, annotations: list[AnnotationResult]) -> dict[str, Any]:
"""构造写入 JSONL 的最终记录。"""
return {
"image_path": str(image_file.resolve()),
"annotations": annotations,
}
async def _append_record(self, record: dict[str, Any]) -> None:
"""以追加模式实时写入 JSONL 文件。"""
self.output_path.parent.mkdir(parents=True, exist_ok=True)
line = json.dumps(record, ensure_ascii=False)
async with self._file_lock:
with self.output_path.open("a", encoding="utf-8") as file:
file.write(line)
file.write("\n")
def _load_processed_paths(self) -> set[str]:
"""读取已存在 JSONL,构建断点续传索引。"""
if not self.output_path.exists():
return set()
processed_paths: set[str] = set()
with self.output_path.open("r", encoding="utf-8") as file:
for line in file:
line = line.strip()
if not line:
continue
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
image_path = payload.get("image_path")
if isinstance(image_path, str):
processed_paths.add(str(Path(image_path).resolve()))
return processed_paths
def _build_image_data_url(self, image_file: Path) -> str:
"""将本地图像编码为 data URL。"""
with image_file.open("rb") as file:
image_bytes = file.read()
image_format = imghdr.what(None, h=image_bytes)
mime_type = self._guess_mime_type(image_file=image_file, image_format=image_format)
encoded = base64.b64encode(image_bytes).decode("utf-8")
return f"data:{mime_type};base64,{encoded}"
def _guess_mime_type(self, image_file: Path, image_format: str | None) -> str:
"""推断图像 MIME 类型。"""
format_name = (image_format or image_file.suffix.lstrip(".")).lower()
if format_name in {"jpg", "jpeg"}:
return "image/jpeg"
if format_name == "png":
return "image/png"
if format_name == "webp":
return "image/webp"
if format_name == "bmp":
return "image/bmp"
raise ValueError(f"不支持的图像格式: {image_file}")
def _validate_annotation_batch(self, payload: dict[str, Any]) -> list[AnnotationResult]:
"""校验多目标结构化结果。"""
annotations = payload.get("annotations")
if not isinstance(annotations, list):
raise ValueError("模型返回必须包含 annotations 数组。")
if not annotations:
raise ValueError("模型未输出任何西红柿目标。")
validated: list[AnnotationResult] = []
for item in annotations:
if not isinstance(item, dict):
raise ValueError("annotations 中每个元素必须为对象。")
validated.append(self._validate_single_annotation(item))
return validated
def _validate_single_annotation(self, payload: dict[str, Any]) -> AnnotationResult:
"""校验单个目标标注结果。"""
required_keys = {"bbox", "maturity_level", "maturity_ratio", "occlusion_degree", "reasoning"}
missing_keys = required_keys.difference(payload.keys())
if missing_keys:
raise ValueError(f"模型返回缺少字段: {sorted(missing_keys)}")
maturity_level = str(payload["maturity_level"])
if maturity_level not in {"未成熟", "半成熟", "完熟"}:
raise ValueError("maturity_level 取值非法。")
maturity_ratio = float(payload["maturity_ratio"])
if not 0.0 <= maturity_ratio <= 1.0:
raise ValueError("maturity_ratio 必须位于 0.0 到 1.0 之间。")
occlusion_degree = str(payload["occlusion_degree"])
if occlusion_degree not in {"无", "轻度", "重度"}:
raise ValueError("occlusion_degree 取值非法。")
reasoning = str(payload["reasoning"]).strip()
if not reasoning:
raise ValueError("reasoning 不能为空。")
return {
"bbox": self._validate_bbox_1000(payload["bbox"]),
"maturity_level": maturity_level,
"maturity_ratio": maturity_ratio,
"occlusion_degree": occlusion_degree,
"reasoning": reasoning,
}
def _validate_bbox_1000(self, bbox: Any) -> list[float]:
"""校验 0-1000 归一化 bbox 合法性。"""
if not isinstance(bbox, list) or len(bbox) != 4:
raise ValueError("bbox 必须是长度为 4 的列表。")
if not all(isinstance(value, (int, float)) for value in bbox):
raise ValueError("bbox 的 4 个坐标必须全部为数值类型。")
x1, y1, x2, y2 = [float(value) for value in bbox]
if max(x1, y1, x2, y2) <= 1.0:
x1, y1, x2, y2 = [value * 1000.0 for value in (x1, y1, x2, y2)]
if min(x1, y1, x2, y2) < 0.0 or max(x1, x2) > 1000.0 or max(y1, y2) > 1000.0:
raise ValueError("bbox 超出 0 到 1000 的归一化坐标范围。")
if x2 <= x1 or y2 <= y1:
raise ValueError("bbox 坐标非法,必须满足 x2 > x1 且 y2 > y1。")
return [x1, y1, x2, y2]
CognitiveAnnotator = CognitiveAnnotatorAgent