hazard.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915
  1. """
  2. Hazard detection routes.
  3. """
  4. from typing import Any, Dict, List, Optional
  5. import io
  6. import json
  7. import time
  8. import traceback
  9. from urllib.parse import urlparse, parse_qs
  10. import httpx
  11. from fastapi import APIRouter, Depends, Request
  12. from pydantic import BaseModel, Field
  13. from sqlalchemy.orm import Session
  14. from PIL import Image, ImageDraw, ImageFont
  15. from database import get_db
  16. from models.scene import RecognitionRecord, Scene, SecondScene, ThirdScene
  17. from services.oss_service import oss_service
  18. from services.yolo_service import yolo_service
  19. from utils.crypto import decrypt_url
  20. from utils.label_translator import translate_labels, translate_scenes_for_query
  21. from utils.logger import logger
  22. router = APIRouter()
  23. class HazardRequest(BaseModel):
  24. """兼容前端与 Go 版请求结构。"""
  25. scene_name: str = ""
  26. scene_type: str = ""
  27. image: str = ""
  28. image_url: str = ""
  29. date: str = ""
  30. title: str = ""
  31. description: str = ""
  32. labels: str = ""
  33. current_step: Optional[int] = None
  34. hazard_count: Optional[int] = None
  35. third_scene: str = ""
  36. second_scene: str = ""
  37. recognition_image_url: str = ""
  38. original_image_url: str = ""
  39. tag_type: str = ""
  40. hazard_details: Any = Field(default_factory=dict)
  41. save_history: bool = True
  42. @property
  43. def source_image(self) -> str:
  44. return self.image or self.image_url or self.original_image_url or ""
  45. @property
  46. def source_scene(self) -> str:
  47. return self.scene_name or self.scene_type or self.tag_type
  48. class SaveStepRequest(BaseModel):
  49. """Save current step for a recognition record."""
  50. record_id: int
  51. current_step: int
  52. SCENE_KEY_ALIASES = {
  53. "tunnel": "tunnel",
  54. "隧道": "tunnel",
  55. "隧道工程": "tunnel",
  56. "simple_supported_bridge": "simple_supported_bridge",
  57. "bridge": "simple_supported_bridge",
  58. "桥梁": "simple_supported_bridge",
  59. "桥梁工程": "simple_supported_bridge",
  60. "gas_station": "gas_station",
  61. "加油站": "gas_station",
  62. "special_equipment": "special_equipment",
  63. "特种设备": "special_equipment",
  64. "operate_highway": "operate_highway",
  65. "运营高速公路": "operate_highway",
  66. }
  67. SCENE_DISPLAY_NAMES = {
  68. "tunnel": "隧道工程",
  69. "simple_supported_bridge": "桥梁工程",
  70. "gas_station": "加油站",
  71. "special_equipment": "特种设备",
  72. "operate_highway": "运营高速公路",
  73. }
  74. DEFAULT_SCENE_KEY = "tunnel"
  75. def _get_user_code(user: Any) -> str:
  76. return (
  77. getattr(user, "userCode", None)
  78. or getattr(user, "user_code", None)
  79. or getattr(user, "account", "")
  80. )
  81. def _resolve_scene_key(scene_value: str) -> str:
  82. if not scene_value:
  83. return ""
  84. return SCENE_KEY_ALIASES.get(scene_value.strip(), scene_value.strip())
  85. def _scene_exists(db: Session, scene_key: str) -> bool:
  86. if not scene_key:
  87. return False
  88. return (
  89. db.query(Scene)
  90. .filter(Scene.scene_en_name == scene_key, Scene.is_deleted == 0)
  91. .first()
  92. is not None
  93. )
  94. def _normalize_label_for_scene(label: str, scene_name: str) -> str:
  95. if _should_trim_scene_label_prefix(scene_name):
  96. return _process_highway_label(label)
  97. return label
  98. def _process_highway_label(label: str) -> str:
  99. underscore_index = label.find("_")
  100. if underscore_index == -1:
  101. return label
  102. return label[underscore_index + 1 :]
  103. def _should_trim_scene_label_prefix(scene_name: str) -> bool:
  104. if not scene_name:
  105. return False
  106. return any(
  107. keyword in scene_name
  108. for keyword in (
  109. "运营高速公路",
  110. "operate_highway",
  111. "隧道",
  112. "tunnel",
  113. "简支梁",
  114. "桥梁",
  115. "simple_supported_bridge",
  116. )
  117. )
  118. def _remove_duplicate_labels(labels: List[str]) -> str:
  119. unique_labels: List[str] = []
  120. seen = set()
  121. for label in labels:
  122. label = str(label).strip()
  123. if label and label not in seen:
  124. seen.add(label)
  125. unique_labels.append(label)
  126. return ", ".join(unique_labels)
  127. def _dedupe_list(items: List[str]) -> List[str]:
  128. seen = set()
  129. result: List[str] = []
  130. for item in items:
  131. if item and item not in seen:
  132. seen.add(item)
  133. result.append(item)
  134. return result
  135. def _resolve_image_url(source_image_url: str) -> str:
  136. """Resolve encrypted/proxy/public image URLs to a directly fetchable URL."""
  137. if not source_image_url:
  138. return source_image_url
  139. url_text = source_image_url.strip()
  140. if not url_text:
  141. return url_text
  142. if "/apiv1/oss/parse/" in url_text:
  143. try:
  144. parsed = urlparse(url_text)
  145. encrypted_url = parse_qs(parsed.query).get("url", [""])[0]
  146. if encrypted_url:
  147. return decrypt_url(encrypted_url)
  148. except Exception:
  149. pass
  150. try:
  151. decrypted_url = decrypt_url(url_text)
  152. if decrypted_url and decrypted_url != url_text:
  153. return decrypted_url
  154. except Exception:
  155. pass
  156. return url_text
  157. def _unique_ordered(items: List[str]) -> List[str]:
  158. seen = set()
  159. ordered = []
  160. for item in items:
  161. if not item or item in seen:
  162. continue
  163. seen.add(item)
  164. ordered.append(item)
  165. return ordered
  166. def _build_scene_relations(
  167. db: Session, normalized_labels: List[str]
  168. ) -> tuple[list[str], dict[str, list[str]]]:
  169. """
  170. Build scene relations by mapping sub-secondary scenes to secondary scenes,
  171. then querying for third scenes (hazards).
  172. Args:
  173. db: Database session
  174. normalized_labels: List of sub-secondary scene names (e.g., "加油机", "加油枪")
  175. Returns:
  176. Tuple of (third_scene_names, element_hazards)
  177. - third_scene_names: List of all third scene names (hazards)
  178. - element_hazards: Dict mapping original labels to their hazards
  179. """
  180. third_scene_names: List[str] = []
  181. element_hazards: Dict[str, List[str]] = {}
  182. for label in normalized_labels:
  183. # Translate sub-secondary scene to secondary scene for database query
  184. # e.g., "加油机" -> "加油设施及附属场地"
  185. query_label = translate_scenes_for_query([label])[0] if label else label
  186. # Query SecondScene table using the translated secondary scene name
  187. second_scene = (
  188. db.query(SecondScene)
  189. .filter(
  190. SecondScene.second_scene_name == query_label,
  191. SecondScene.is_deleted == 0,
  192. )
  193. .first()
  194. )
  195. if not second_scene:
  196. # No matching secondary scene found, store empty hazards list
  197. element_hazards.setdefault(label, [])
  198. continue
  199. # Query ThirdScene table for hazards related to this secondary scene
  200. third_scenes = (
  201. db.query(ThirdScene)
  202. .filter(
  203. ThirdScene.second_scene_id == second_scene.id,
  204. ThirdScene.is_deleted == 0,
  205. )
  206. .all()
  207. )
  208. if third_scenes:
  209. current_element_hazards = [item.third_scene_name for item in third_scenes]
  210. third_scene_names.extend(current_element_hazards)
  211. # Store hazards under the ORIGINAL sub-secondary scene label
  212. element_hazards[label] = _dedupe_list(
  213. element_hazards.get(label, []) + current_element_hazards
  214. )
  215. else:
  216. element_hazards.setdefault(label, [])
  217. return _dedupe_list(third_scene_names), element_hazards
  218. @router.post("/hazard")
  219. async def hazard(
  220. request: Request,
  221. data: HazardRequest,
  222. db: Session = Depends(get_db),
  223. ):
  224. """隐患识别接口:严格按 Go 版的流程、字段和返回结构实现。"""
  225. user = request.state.user
  226. if not user:
  227. logger.warning("[hazard] request.state.user is empty")
  228. return {"statusCode": 401, "msg": "获取用户信息失败"}
  229. user_id = getattr(user, "id", None) or getattr(user, "user_id", None) or 1
  230. user_code = _get_user_code(user)
  231. request_id = getattr(request.state, "request_id", None) or f"hazard-{int(time.time() * 1000)}"
  232. try:
  233. logger.info(
  234. "[hazard][%s] incoming payload: user_id=%r, user_code=%r, scene_name=%r, scene_type=%r, image_present=%s, image_url_present=%s, image_len=%s, image_url_len=%s, date=%r",
  235. request_id,
  236. user_id,
  237. user_code,
  238. data.scene_name,
  239. data.scene_type,
  240. bool(data.image),
  241. bool(data.image_url),
  242. len(data.image or ""),
  243. len(data.image_url or ""),
  244. data.date,
  245. )
  246. logger.info(
  247. "[hazard][%s] request headers: content_type=%r, content_length=%r, user_agent=%r, path=%r, method=%r",
  248. request_id,
  249. request.headers.get("content-type"),
  250. request.headers.get("content-length"),
  251. request.headers.get("user-agent"),
  252. request.url.path,
  253. request.method,
  254. )
  255. source_image_url = _resolve_image_url(data.source_image)
  256. logger.info(
  257. "[hazard][%s] resolved image url: raw=%r, resolved=%r",
  258. request_id,
  259. data.source_image,
  260. source_image_url,
  261. )
  262. scene_key = _resolve_scene_key(data.source_scene)
  263. if not scene_key:
  264. scene_key = DEFAULT_SCENE_KEY
  265. logger.info(
  266. "[hazard][%s] resolved scene: source_scene=%r, scene_key=%r",
  267. request_id,
  268. data.source_scene,
  269. scene_key,
  270. )
  271. if not source_image_url:
  272. logger.warning(
  273. "[hazard][%s] validation failed: scene_name=%r, scene_type=%r, image_present=%s, image_url_present=%s, raw_image_len=%s, raw_image_url_len=%s",
  274. request_id,
  275. data.scene_name,
  276. data.scene_type,
  277. bool(data.image),
  278. bool(data.image_url),
  279. len(data.image or ""),
  280. len(data.image_url or ""),
  281. )
  282. return {"statusCode": 400, "msg": "图片链接不能为空"}
  283. scene_exists = _scene_exists(db, scene_key)
  284. logger.info(
  285. "[hazard][%s] scene lookup result: scene_key=%r, exists=%s",
  286. request_id,
  287. scene_key,
  288. scene_exists,
  289. )
  290. if not scene_exists:
  291. logger.warning("[hazard][%s] scene not found: %r", request_id, scene_key)
  292. return {"statusCode": 500, "msg": "场景名称不存在"}
  293. logger.info(
  294. "[hazard][%s] downloading image: url=%r",
  295. request_id,
  296. source_image_url,
  297. )
  298. try:
  299. async with httpx.AsyncClient(timeout=30.0) as client:
  300. img_response = await client.get(source_image_url)
  301. logger.info(
  302. "[hazard][%s] image download response: status_code=%s, content_type=%r, content_length=%s",
  303. request_id,
  304. img_response.status_code,
  305. img_response.headers.get("content-type"),
  306. img_response.headers.get("content-length"),
  307. )
  308. img_response.raise_for_status()
  309. image_bytes = img_response.content
  310. except httpx.HTTPError as e:
  311. logger.error(
  312. "[hazard][%s] 下载图片失败: url=%r, error=%s, traceback=%s",
  313. request_id,
  314. source_image_url,
  315. e,
  316. traceback.format_exc(),
  317. )
  318. error_msg = "图片下载失败"
  319. if isinstance(e, httpx.TimeoutException):
  320. error_msg = "图片下载超时,请检查网络连接或稍后重试"
  321. elif isinstance(e, httpx.ConnectError):
  322. error_msg = "无法连接到图片服务器,请检查图片链接是否正确"
  323. elif hasattr(e, 'response') and e.response is not None:
  324. status_code = e.response.status_code
  325. if status_code == 404:
  326. error_msg = "图片不存在,请检查图片链接是否正确"
  327. elif status_code == 403:
  328. error_msg = "无权访问该图片,请检查图片链接权限"
  329. elif status_code >= 500:
  330. error_msg = "图片服务器暂时不可用,请稍后重试"
  331. else:
  332. error_msg = f"图片下载失败(错误代码:{status_code})"
  333. return {"statusCode": 500, "msg": error_msg}
  334. logger.info(
  335. "[hazard][%s] image downloaded: bytes=%s",
  336. request_id,
  337. len(image_bytes),
  338. )
  339. try:
  340. logger.info("[hazard][%s] calling yolo_service.detect_hazards: scene_key=%r", request_id, scene_key)
  341. yolo_result = await yolo_service.detect_hazards(image_bytes, scene_key)
  342. logger.info(
  343. "[hazard][%s] yolo result summary: keys=%s, labels_count=%s, boxes_count=%s, scores_count=%s, model_type=%r",
  344. request_id,
  345. list(yolo_result.keys()) if isinstance(yolo_result, dict) else type(yolo_result).__name__,
  346. len((yolo_result or {}).get("labels", []) or []) if isinstance(yolo_result, dict) else -1,
  347. len((yolo_result or {}).get("boxes", []) or []) if isinstance(yolo_result, dict) else -1,
  348. len((yolo_result or {}).get("scores", []) or []) if isinstance(yolo_result, dict) else -1,
  349. (yolo_result or {}).get("model_type", "") if isinstance(yolo_result, dict) else "",
  350. )
  351. logger.debug("[hazard][%s] yolo result raw: %s", request_id, json.dumps(yolo_result, ensure_ascii=False, default=str))
  352. except httpx.TimeoutException as e:
  353. logger.error(
  354. "[hazard][%s] YOLO检测超时: scene_key=%r, error=%s, traceback=%s",
  355. request_id,
  356. scene_key,
  357. e,
  358. traceback.format_exc(),
  359. )
  360. return {"statusCode": 500, "msg": "AI识别服务响应超时,请稍后重试"}
  361. except httpx.ConnectError as e:
  362. logger.error(
  363. "[hazard][%s] YOLO服务连接失败: scene_key=%r, error=%s, traceback=%s",
  364. request_id,
  365. scene_key,
  366. e,
  367. traceback.format_exc(),
  368. )
  369. return {"statusCode": 500, "msg": "无法连接到AI识别服务,请联系管理员"}
  370. except httpx.HTTPStatusError as e:
  371. logger.error(
  372. "[hazard][%s] YOLO服务返回错误状态: scene_key=%r, status_code=%s, error=%s, traceback=%s",
  373. request_id,
  374. scene_key,
  375. e.response.status_code if hasattr(e, 'response') else 'unknown',
  376. e,
  377. traceback.format_exc(),
  378. )
  379. status_code = e.response.status_code if hasattr(e, 'response') else 500
  380. if status_code == 400:
  381. return {"statusCode": 500, "msg": "图片格式不支持或图片已损坏,请更换图片"}
  382. elif status_code == 404:
  383. return {"statusCode": 500, "msg": f"未找到场景'{SCENE_DISPLAY_NAMES.get(scene_key, scene_key)}'的识别模型"}
  384. elif status_code >= 500:
  385. return {"statusCode": 500, "msg": "AI识别服务暂时不可用,请稍后重试"}
  386. else:
  387. return {"statusCode": 500, "msg": f"AI识别服务返回错误(代码:{status_code})"}
  388. except ValueError as e:
  389. logger.error(
  390. "[hazard][%s] YOLO返回数据格式错误: scene_key=%r, error=%s, traceback=%s",
  391. request_id,
  392. scene_key,
  393. e,
  394. traceback.format_exc(),
  395. )
  396. return {"statusCode": 500, "msg": "AI识别服务返回数据格式错误,请联系管理员"}
  397. except Exception as e:
  398. logger.error(
  399. "[hazard][%s] YOLO检测失败: scene_key=%r, error=%s, traceback=%s",
  400. request_id,
  401. scene_key,
  402. e,
  403. traceback.format_exc(),
  404. )
  405. error_msg = str(e)
  406. if "timeout" in error_msg.lower():
  407. return {"statusCode": 500, "msg": "AI识别服务响应超时,请稍后重试"}
  408. elif "connection" in error_msg.lower():
  409. return {"statusCode": 500, "msg": "无法连接到AI识别服务,请联系管理员"}
  410. else:
  411. return {"statusCode": 500, "msg": f"AI识别失败:{error_msg}"}
  412. labels = yolo_result.get("labels", []) or []
  413. boxes = yolo_result.get("boxes", []) or []
  414. scores = yolo_result.get("scores", []) or []
  415. model_type = yolo_result.get("model_type", "")
  416. if not labels:
  417. logger.info(
  418. "[hazard][%s] YOLO返回空结果: scene_key=%r, model_type=%r, image_bytes=%s",
  419. request_id,
  420. scene_key,
  421. model_type,
  422. len(image_bytes),
  423. )
  424. return {
  425. "statusCode": 200,
  426. "msg": "未检测到隐患",
  427. "data": {
  428. "scene_name": scene_key,
  429. "scene_display_name": SCENE_DISPLAY_NAMES.get(scene_key, scene_key),
  430. "tag_type": scene_key,
  431. "title": f"{SCENE_DISPLAY_NAMES.get(scene_key, scene_key)}隐患提示",
  432. "total_detections": 0,
  433. "detections": [],
  434. "model_type": model_type,
  435. "original_image": source_image_url,
  436. "annotated_image": source_image_url,
  437. "labels": "",
  438. "display_labels": [],
  439. "third_scenes": [],
  440. "element_hazards": {},
  441. "no_hazards_detected": True,
  442. },
  443. }
  444. normalized_labels = [
  445. _normalize_label_for_scene(str(label), scene_key) for label in labels
  446. ]
  447. logger.info(
  448. "[hazard][%s] normalized labels: raw_labels=%s, normalized_labels=%s",
  449. request_id,
  450. labels,
  451. normalized_labels,
  452. )
  453. # Translate pinyin/English labels to Chinese
  454. translated_labels = translate_labels(normalized_labels, fallback_to_original=True)
  455. logger.info(
  456. "[hazard][%s] translated labels: before=%s, after=%s",
  457. request_id,
  458. normalized_labels,
  459. translated_labels,
  460. )
  461. normalized_labels = translated_labels
  462. detection_results = []
  463. for i, label in enumerate(normalized_labels):
  464. if i < len(scores) and i < len(boxes):
  465. detection_results.append(
  466. {
  467. "label": label,
  468. "score": scores[i],
  469. "box": boxes[i],
  470. "percent": f"{scores[i] * 100:.2f}%",
  471. }
  472. )
  473. logger.info(
  474. "[hazard][%s] detection results built: total=%s, detail=%s",
  475. request_id,
  476. len(detection_results),
  477. detection_results,
  478. )
  479. third_scene_names, element_hazards = _build_scene_relations(
  480. db, normalized_labels
  481. )
  482. logger.info(
  483. "[hazard][%s] scene relations built: third_scene_names=%s, element_hazards=%s",
  484. request_id,
  485. third_scene_names,
  486. element_hazards,
  487. )
  488. scene_name = scene_key
  489. scene_display_name = SCENE_DISPLAY_NAMES.get(scene_key, scene_name)
  490. tag_type = scene_key
  491. title = scene_display_name
  492. description = " ".join(third_scene_names)
  493. current_step = 1
  494. hazard_count = len(detection_results)
  495. # 不再绘制检测框,直接使用原始图片
  496. logger.info(
  497. "[hazard][%s] skipping image annotation, using original image",
  498. request_id,
  499. )
  500. recognition_image_url = source_image_url
  501. try:
  502. labels_text = _remove_duplicate_labels([item["label"] for item in detection_results])
  503. if data.labels.strip():
  504. labels_text = _remove_duplicate_labels(
  505. [item.strip() for item in data.labels.split(",") if item.strip()]
  506. ) or labels_text
  507. record_title = data.title.strip() or title
  508. record_description = data.description.strip() or description
  509. record_third_scene = data.third_scene.strip() or " ".join(third_scene_names)
  510. record_second_scene = data.second_scene.strip()
  511. record_tag_type = data.tag_type.strip() or tag_type
  512. record_current_step = data.current_step if data.current_step is not None else current_step
  513. record_hazard_count = (
  514. data.hazard_count if data.hazard_count is not None else hazard_count
  515. )
  516. raw_hazard_details = data.hazard_details
  517. if isinstance(raw_hazard_details, str):
  518. try:
  519. record_hazard_details = json.loads(raw_hazard_details)
  520. except Exception:
  521. record_hazard_details = raw_hazard_details
  522. elif raw_hazard_details:
  523. record_hazard_details = raw_hazard_details
  524. else:
  525. record_hazard_details = detection_results
  526. recognition_image_url = (
  527. data.recognition_image_url.strip() or recognition_image_url
  528. )
  529. now_ts = int(time.time())
  530. record_payload = {
  531. "original_image_url": source_image_url,
  532. "recognition_image_url": recognition_image_url,
  533. "user_id": user_id,
  534. "scene_type": scene_name,
  535. "hazard_count": record_hazard_count,
  536. "current_step": record_current_step,
  537. "hazard_details": json.dumps(record_hazard_details, ensure_ascii=False)
  538. if not isinstance(record_hazard_details, str)
  539. else record_hazard_details,
  540. "title": record_title,
  541. "description": record_description,
  542. "labels": labels_text,
  543. "tag_type": record_tag_type,
  544. "second_scene": record_second_scene,
  545. "third_scene": record_third_scene,
  546. "created_at": now_ts,
  547. "updated_at": now_ts,
  548. "is_deleted": 0,
  549. }
  550. logger.info(
  551. "[hazard][%s] ===== HISTORY SAVE START ===== user_id=%r, scene_key=%r, labels=%r, title=%r, save_history=%s",
  552. request_id,
  553. user_id,
  554. scene_key,
  555. labels_text,
  556. record_title,
  557. data.save_history,
  558. )
  559. logger.info(
  560. "[hazard][%s] history record payload: %s",
  561. request_id,
  562. json.dumps(record_payload, ensure_ascii=False, default=str),
  563. )
  564. record = RecognitionRecord(**record_payload)
  565. db.add(record)
  566. logger.info("[hazard][%s] record added to session, committing db transaction", request_id)
  567. db.commit()
  568. logger.info("[hazard][%s] db commit success, refreshing record", request_id)
  569. db.refresh(record)
  570. logger.info(
  571. "[hazard][%s] record saved successfully: id=%r, current_step=%r, created_at=%r, updated_at=%r",
  572. request_id,
  573. getattr(record, "id", None),
  574. getattr(record, "current_step", None),
  575. getattr(record, "created_at", None),
  576. getattr(record, "updated_at", None),
  577. )
  578. except Exception as e:
  579. logger.error(
  580. "[hazard][%s] 识别失败(保存记录阶段): error=%s, traceback=%s",
  581. request_id,
  582. e,
  583. traceback.format_exc(),
  584. )
  585. db.rollback()
  586. logger.info("[hazard][%s] db rollback executed after record save failure", request_id)
  587. error_msg = str(e)
  588. if "connection" in error_msg.lower() or "lost connection" in error_msg.lower():
  589. return {"statusCode": 500, "msg": "数据库连接失败,请稍后重试"}
  590. elif "duplicate" in error_msg.lower() or "unique" in error_msg.lower():
  591. return {"statusCode": 500, "msg": "记录已存在,请勿重复提交"}
  592. elif "timeout" in error_msg.lower():
  593. return {"statusCode": 500, "msg": "数据库操作超时,请稍后重试"}
  594. elif "disk" in error_msg.lower() or "space" in error_msg.lower():
  595. return {"statusCode": 500, "msg": "存储空间不足,请联系管理员"}
  596. else:
  597. return {"statusCode": 500, "msg": f"保存识别记录失败:{error_msg}"}
  598. display_labels = _dedupe_list([item["label"] for item in detection_results])
  599. response_data = {
  600. "scene_name": scene_name,
  601. "scene_display_name": scene_display_name,
  602. "tag_type": tag_type,
  603. "title": record_title,
  604. "description": record_description,
  605. "current_step": record_current_step,
  606. "hazard_count": record_hazard_count,
  607. "total_detections": len(detection_results),
  608. "detections": detection_results,
  609. "model_type": model_type,
  610. "original_image": source_image_url,
  611. "annotated_image": recognition_image_url,
  612. "recognition_image_url": recognition_image_url,
  613. "labels": labels_text,
  614. "display_labels": display_labels,
  615. "third_scenes": third_scene_names,
  616. "element_hazards": element_hazards,
  617. "record_id": getattr(record, "id", None),
  618. }
  619. logger.info(
  620. "[hazard][%s] response prepared successfully: %s",
  621. request_id,
  622. json.dumps(response_data, ensure_ascii=False, default=str),
  623. )
  624. return {
  625. "statusCode": 200,
  626. "msg": "识别成功",
  627. "data": response_data,
  628. }
  629. except Exception as e:
  630. logger.error(
  631. "[hazard][%s] 处理失败: error=%s, traceback=%s",
  632. request_id,
  633. e,
  634. traceback.format_exc(),
  635. )
  636. db.rollback()
  637. logger.info("[hazard][%s] db rollback executed in outer exception handler", request_id)
  638. error_msg = str(e)
  639. if "connection" in error_msg.lower():
  640. return {"statusCode": 500, "msg": "服务连接失败,请稍后重试"}
  641. elif "timeout" in error_msg.lower():
  642. return {"statusCode": 500, "msg": "服务响应超时,请稍后重试"}
  643. elif "memory" in error_msg.lower() or "out of memory" in error_msg.lower():
  644. return {"statusCode": 500, "msg": "系统资源不足,请稍后重试或联系管理员"}
  645. elif "permission" in error_msg.lower() or "denied" in error_msg.lower():
  646. return {"statusCode": 500, "msg": "权限不足,请联系管理员"}
  647. else:
  648. return {"statusCode": 500, "msg": f"识别处理失败:{error_msg}"}
  649. @router.post("/save_step")
  650. async def save_step(
  651. request: Request,
  652. data: SaveStepRequest,
  653. db: Session = Depends(get_db),
  654. ):
  655. """Update RecognitionRecord.current_step."""
  656. user = request.state.user
  657. if not user:
  658. logger.warning("[save_step] unauthorized request: request.state.user is empty")
  659. return {"statusCode": 401, "msg": "未授权"}
  660. user_id = getattr(user, "user_id", None) or getattr(user, "id", None)
  661. request_id = getattr(request.state, "request_id", None) or f"save-step-{int(time.time() * 1000)}"
  662. try:
  663. logger.info(
  664. "[save_step][%s] incoming payload: user_id=%r, record_id=%r, current_step=%r, path=%r, method=%r",
  665. request_id,
  666. user_id,
  667. data.record_id,
  668. data.current_step,
  669. request.url.path,
  670. request.method,
  671. )
  672. logger.info(
  673. "[save_step][%s] updating RecognitionRecord: record_id=%r, user_id=%r",
  674. request_id,
  675. data.record_id,
  676. user_id,
  677. )
  678. affected = (
  679. db.query(RecognitionRecord)
  680. .filter(
  681. RecognitionRecord.id == data.record_id,
  682. RecognitionRecord.user_id == user_id,
  683. )
  684. .update(
  685. {
  686. "current_step": data.current_step,
  687. "updated_at": int(time.time()),
  688. }
  689. )
  690. )
  691. logger.info("[save_step][%s] update result: affected_rows=%s", request_id, affected)
  692. if affected == 0:
  693. logger.warning(
  694. "[save_step][%s] record not found or no permission: record_id=%r, user_id=%r",
  695. request_id,
  696. data.record_id,
  697. user_id,
  698. )
  699. return {"statusCode": 404, "msg": "记录不存在"}
  700. logger.info("[save_step][%s] committing db transaction", request_id)
  701. db.commit()
  702. logger.info(
  703. "[save_step][%s] save success: record_id=%r, current_step=%r",
  704. request_id,
  705. data.record_id,
  706. data.current_step,
  707. )
  708. return {
  709. "statusCode": 200,
  710. "msg": "保存成功",
  711. "data": {
  712. "record_id": data.record_id,
  713. "current_step": data.current_step,
  714. },
  715. }
  716. except Exception as e:
  717. logger.error(
  718. "[save_step][%s] 异常: error=%s, traceback=%s",
  719. request_id,
  720. e,
  721. traceback.format_exc(),
  722. )
  723. db.rollback()
  724. logger.info("[save_step][%s] db rollback executed after save_step failure", request_id)
  725. return {"statusCode": 500, "msg": f"保存失败: {str(e)}"}
  726. async def _draw_boxes_and_watermark(
  727. image_bytes: bytes,
  728. hazards: List[Dict[str, Any]],
  729. user_name: str,
  730. user_account: str,
  731. ) -> bytes:
  732. """Draw detection boxes and a tiled watermark on the image."""
  733. try:
  734. image = Image.open(io.BytesIO(image_bytes)).convert("RGBA")
  735. width, height = image.size
  736. overlay = Image.new("RGBA", (width, height), (255, 255, 255, 0))
  737. draw = ImageDraw.Draw(overlay)
  738. try:
  739. font = ImageFont.truetype(
  740. "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20
  741. )
  742. font_small = ImageFont.truetype(
  743. "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14
  744. )
  745. except Exception:
  746. try:
  747. font = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 20)
  748. font_small = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 14)
  749. except Exception:
  750. font = ImageFont.load_default()
  751. font_small = ImageFont.load_default()
  752. for hazard in hazards:
  753. bbox = hazard.get("bbox", []) or hazard.get("box", [])
  754. label = hazard.get("label", "")
  755. confidence = hazard.get("confidence", 0)
  756. if len(bbox) == 4:
  757. x1, y1, x2, y2 = bbox
  758. draw.rectangle([x1, y1, x2, y2], outline=(255, 0, 0, 255), width=3)
  759. text = f"{label} {confidence:.2f}"
  760. draw.text(
  761. (x1, max(0, y1 - 25)),
  762. text,
  763. fill=(255, 0, 0, 255),
  764. font=font,
  765. )
  766. current_date = time.strftime("%Y/%m/%d")
  767. watermarks = [user_name or "", user_account or "", current_date]
  768. watermarks = [text for text in watermarks if text]
  769. if not watermarks:
  770. watermarks = [current_date]
  771. text_height_estimate = 50
  772. text_width_estimate = 150
  773. angle = 45
  774. watermark_layer = Image.new(
  775. "RGBA", (width * 2, height * 2), (255, 255, 255, 0)
  776. )
  777. watermark_draw = ImageDraw.Draw(watermark_layer)
  778. for y in range(-height, height * 2, text_height_estimate):
  779. for x in range(-width, width * 2, text_width_estimate):
  780. row_index = int(y / text_height_estimate) % len(watermarks)
  781. watermark_draw.text(
  782. (x, y),
  783. watermarks[row_index],
  784. fill=(128, 128, 128, 60),
  785. font=font_small,
  786. )
  787. watermark_layer = watermark_layer.rotate(
  788. angle, expand=False, fillcolor=(255, 255, 255, 0)
  789. )
  790. crop_x = (watermark_layer.width - width) // 2
  791. crop_y = (watermark_layer.height - height) // 2
  792. watermark_layer = watermark_layer.crop(
  793. (crop_x, crop_y, crop_x + width, crop_y + height)
  794. )
  795. image = Image.alpha_composite(image, watermark_layer)
  796. image = Image.alpha_composite(image, overlay)
  797. final_image = image.convert("RGB")
  798. output = io.BytesIO()
  799. final_image.save(output, format="JPEG", quality=95)
  800. return output.getvalue()
  801. except Exception as e:
  802. logger.error(
  803. "[_draw_boxes_and_watermark] 图片处理失败: %s, traceback=%s",
  804. e,
  805. traceback.format_exc(),
  806. )
  807. return image_bytes