formate_xy.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  1. import requests
  2. import json
  3. from enum import Enum
  4. from fastapi import APIRouter, Depends, HTTPException, Query, Body
  5. from fastapi.concurrency import run_in_threadpool
  6. from mysql.connector.pooling import PooledMySQLConnection
  7. from app.core.config import settings
  8. from app.core.logger import get_logger
  9. from app.core.database_loader import get_db_connection
  10. from app.api.users import check_card_permission, get_current_user
  11. from app.utils.scheme import (
  12. CardDetailResponse, IMAGE_TYPE_TO_SCORE_TYPE, ImageType
  13. )
  14. from app.crud import crud_card
  15. from app.utils.xy_process import convert_internal_to_xy_format, convert_xy_to_internal_format
  16. import hashlib
  17. from app.core.minio_client import minio_client
  18. from app.utils.rating_report_utils import crop_defect_image
  19. logger = get_logger(__name__)
  20. router = APIRouter()
  21. db_dependency = Depends(get_db_connection)
  22. class QueryMode(str, Enum):
  23. current = "current"
  24. next = "next"
  25. prev = "prev"
  26. def _process_defects_for_json(card_id: int, img_id: int, img_path: str, json_data: dict, side: str, all_images: list = None):
  27. if not json_data or "result" not in json_data:
  28. return
  29. defect_result = json_data["result"].get("defect_result", {})
  30. defects = defect_result.get("defects", [])
  31. is_fusion = side in ("front_fusion", "back_fusion")
  32. side_prefix = "front_" if side.startswith("front_") else "back_"
  33. defect_detail_list = []
  34. for idx, defect in enumerate(defects, start=1):
  35. min_rect = defect.get("min_rect")
  36. defect_img_url = ""
  37. location_str = ""
  38. defect_img_url_list = []
  39. if min_rect and len(min_rect) == 3:
  40. center_x, center_y = min_rect[0]
  41. location_str = f"{int(center_x)},{int(center_y)}"
  42. # 使用坐标哈希作为缓存文件名,避免重复裁剪
  43. rect_str = str(min_rect)
  44. rect_hash = hashlib.md5(rect_str.encode('utf-8')).hexdigest()[:8]
  45. filename = f"xy_{card_id}_{img_id}_{idx}_{rect_hash}.jpg"
  46. out_rel_path = f"/DefectImage/{filename}"
  47. out_object_name = f"{settings.MINIO_BASE_PREFIX}{out_rel_path}"
  48. try:
  49. # 检查 MinIO 中是否已有该截图,有则直接使用
  50. minio_client.stat_object(settings.MINIO_BUCKET, out_object_name)
  51. defect_img_url = settings.get_full_url(out_rel_path)
  52. except Exception:
  53. # 不存在或异常,则执行裁剪并上传
  54. defect_img_url = crop_defect_image(img_path, min_rect, filename)
  55. # 把同面的其他类型图在同样位置截图(不论是不是融合图都截)
  56. if all_images:
  57. same_side_images = [img for img in all_images if getattr(img, 'image_type', '').startswith(side_prefix)]
  58. for s_img in same_side_images:
  59. s_img_type = getattr(s_img, 'image_type', '')
  60. s_img_path = getattr(s_img, 'image_path', '')
  61. s_img_id = getattr(s_img, 'id', 0)
  62. s_filename = f"xy_{card_id}_{s_img_id}_{idx}_{rect_hash}.jpg"
  63. s_out_rel_path = f"/DefectImage/{s_filename}"
  64. s_out_object_name = f"{settings.MINIO_BASE_PREFIX}{s_out_rel_path}"
  65. s_url = ""
  66. try:
  67. minio_client.stat_object(settings.MINIO_BUCKET, s_out_object_name)
  68. s_url = settings.get_full_url(s_out_rel_path)
  69. except Exception:
  70. if s_img_path:
  71. s_url = crop_defect_image(s_img_path, min_rect, s_filename)
  72. if s_url:
  73. defect_img_url_list.append({
  74. "image_type": s_img_type,
  75. "url": s_url
  76. })
  77. # 1. 给每条缺陷带上 defectImgUrl
  78. defect["defectImgUrl"] = defect_img_url
  79. defect["defectImgUrls"] = defect_img_url_list
  80. # 2. 组装 defectDetailList 元素
  81. raw_type = f"{defect.get('defect_type', '')}".upper().strip()
  82. type_str_map = {
  83. "CORNER": "CORNER",
  84. "EDGE": "SIDE",
  85. "FACE": "SURFACE"
  86. }
  87. type_str = type_str_map.get(raw_type, raw_type)
  88. detail_item = {
  89. "id": defect.get("id", idx),
  90. "side": side,
  91. "location": location_str,
  92. "type": type_str,
  93. "defectImgUrl": defect_img_url,
  94. "label": defect.get("label", ""),
  95. "actual_area": defect.get("actual_area", 0),
  96. "defectImgUrls": defect_img_url_list
  97. }
  98. defect_detail_list.append(detail_item)
  99. defect_result["defectDetailList"] = defect_detail_list
  100. def _process_images_to_xy_format(card_data: dict):
  101. """
  102. 内部辅助函数:遍历卡牌数据中的图片,将 JSON 格式转换为前端需要的 XY 格式。
  103. 直接修改传入的 card_data 字典。
  104. """
  105. card_id = card_data.get("id")
  106. all_images = card_data.get("images", [])
  107. if all_images:
  108. for img in all_images:
  109. # 处理 detection_json
  110. if img.detection_json:
  111. d_json = img.detection_json
  112. if isinstance(d_json, str):
  113. d_json = json.loads(d_json)
  114. _process_defects_for_json(card_id, img.id, img.image_path, d_json, img.image_type, all_images)
  115. # *** 转换逻辑 ***
  116. img.detection_json = convert_internal_to_xy_format(d_json)
  117. # 处理 modified_json
  118. if img.modified_json:
  119. m_json = img.modified_json
  120. if isinstance(m_json, str):
  121. m_json = json.loads(m_json)
  122. _process_defects_for_json(card_id, img.id, img.image_path, m_json, img.image_type, all_images)
  123. # *** 转换逻辑 ***
  124. img.modified_json = convert_internal_to_xy_format(m_json)
  125. return card_data
  126. @router.get("/query", response_model=CardDetailResponse, summary="获取卡牌详细信息(格式化xy), 支持前后翻页 [用户调用]")
  127. def get_card_details(
  128. card_id: int = Query(..., description="基准卡牌ID"),
  129. mode: QueryMode = Query(QueryMode.current, description="查询模式: current(当前), next(下一个), prev(上一个)"),
  130. db_conn: PooledMySQLConnection = db_dependency
  131. ):
  132. """
  133. 获取卡牌元数据以及所有与之关联的图片信息,并将坐标转换为 xy 格式。
  134. 同时返回上一张和下一张卡牌的ID。
  135. - **current**: 查询 card_id 对应的卡牌。
  136. - **next**: 查询 ID 比 card_id 大的第一张卡牌。
  137. - **prev**: 查询 ID 比 card_id 小的第一张卡牌。
  138. """
  139. target_id = card_id
  140. cursor = None
  141. try:
  142. cursor = db_conn.cursor(dictionary=True)
  143. # 1. 如果是查询上一个或下一个,先计算目标ID
  144. if mode != QueryMode.current:
  145. if mode == QueryMode.next:
  146. query_target = (f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} "
  147. f"WHERE id > %s ORDER BY id ASC LIMIT 1")
  148. else: # mode == QueryMode.prev
  149. query_target = (f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} "
  150. f"WHERE id < %s ORDER BY id DESC LIMIT 1")
  151. cursor.execute(query_target, (card_id,))
  152. row = cursor.fetchone()
  153. if not row:
  154. msg = "没有下一张" if mode == QueryMode.next else "没有上一张"
  155. raise HTTPException(status_code=200, detail=msg)
  156. target_id = row['id']
  157. # 2. 获取目标卡牌的详细数据 (Dict 格式)
  158. card_data = crud_card.get_card_with_details(db_conn, target_id)
  159. if not card_data:
  160. raise HTTPException(status_code=404, detail=f"ID为 {target_id} 的卡牌未找到。")
  161. # 3. 补充当前目标卡牌的 id_prev 和 id_next
  162. # 注意:这里需要重新获取 cursor,或者使用 cursor (非 dict 模式可能更方便取值,但 dict 模式也行)
  163. # 这里为了简单直接用 raw SQL
  164. # 查询上一个ID
  165. sql_prev = f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} WHERE id < %s ORDER BY id DESC LIMIT 1"
  166. cursor.execute(sql_prev, (target_id,))
  167. row_prev = cursor.fetchone()
  168. card_data['id_prev'] = row_prev['id'] if row_prev else None
  169. # 查询下一个ID
  170. sql_next = f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} WHERE id > %s ORDER BY id ASC LIMIT 1"
  171. cursor.execute(sql_next, (target_id,))
  172. row_next = cursor.fetchone()
  173. card_data['id_next'] = row_next['id'] if row_next else None
  174. # 4. 遍历图片,转换格式 (使用抽取出的辅助函数)
  175. _process_images_to_xy_format(card_data)
  176. # 5. 验证并返回
  177. return CardDetailResponse.model_validate(card_data)
  178. except HTTPException:
  179. raise
  180. except Exception as e:
  181. logger.error(f"查询卡牌详情失败 (Mode: {mode}, BaseID: {card_id}): {e}")
  182. raise HTTPException(status_code=500, detail="数据库查询失败")
  183. finally:
  184. if cursor:
  185. cursor.close()
  186. @router.put("/update/json/{id}", status_code=200, summary="接收xy格式, 还原后重计算分数并保存 [用户调用]")
  187. async def update_image_modified_json(
  188. id: int,
  189. new_json_data: dict = Body(..., description="前端传来的包含xy对象格式的JSON"),
  190. current_user: dict = Depends(get_current_user),
  191. db_conn: PooledMySQLConnection = db_dependency
  192. ):
  193. """
  194. 接收前端传来的特殊格式 JSON (points 为对象列表)。
  195. 1. 将格式还原为后端标准格式 (points 为 [[x,y]])。
  196. 2. 根据 id 获取 image_type。
  197. 3. 调用外部接口重新计算分数。
  198. 4. 更新 modified_json。
  199. """
  200. card_id_to_update = None
  201. cursor = None
  202. # *** 1. 格式还原 ***
  203. # 将前端的 xy dict 格式转回 [[x,y]],并丢弃 points 里的 id
  204. internal_json_payload = convert_xy_to_internal_format(new_json_data)
  205. try:
  206. cursor = db_conn.cursor(dictionary=True)
  207. # 2. 获取图片信息
  208. cursor.execute(f"SELECT image_type, card_id FROM {settings.DB_IMAGE_TABLE_NAME} WHERE id = %s", (id,))
  209. row = cursor.fetchone()
  210. if not row:
  211. raise HTTPException(status_code=404, detail=f"ID为 {id} 的图片未找到。")
  212. card_id_to_update = row["card_id"]
  213. check_card_permission(db_conn, current_user, card_id_to_update)
  214. image_type = row["image_type"]
  215. score_type = IMAGE_TYPE_TO_SCORE_TYPE.get(image_type)
  216. if not score_type:
  217. raise HTTPException(status_code=400, detail=f"未知的 image_type: {image_type}")
  218. logger.info(f"开始计算分数 (ID: {id}, Type: {score_type})")
  219. # 3. 调用远程计算接口 (使用还原后的 JSON)
  220. try:
  221. response = await run_in_threadpool(
  222. lambda: requests.post(
  223. settings.SCORE_RECALCULATE_ENDPOINT,
  224. params={"score_type": score_type},
  225. json=internal_json_payload, # 传递还原后的数据
  226. timeout=20
  227. )
  228. )
  229. except Exception as e:
  230. raise HTTPException(status_code=500, detail=f"调用分数计算服务失败: {e}")
  231. if response.status_code != 200:
  232. logger.error(f"分数计算接口返回错误: {response.status_code}, {response.text}")
  233. raise HTTPException(status_code=response.status_code,
  234. detail=f"分数计算接口返回错误: {response.text}")
  235. logger.info("分数计算完成")
  236. # 获取计算服务返回的结果(这个结果通常已经是标准的 internal 格式,带有分数和面积)
  237. final_json_data = response.json()
  238. # 4. 保存结果到数据库
  239. recalculated_json_str = json.dumps(final_json_data, ensure_ascii=False)
  240. update_query = (f"UPDATE {settings.DB_IMAGE_TABLE_NAME} "
  241. f"SET modified_json = %s, is_edited = TRUE "
  242. f"WHERE id = %s")
  243. cursor.execute(update_query, (recalculated_json_str, id))
  244. db_conn.commit()
  245. logger.info(f"图片ID {id} 的 modified_json 已更新并重新计算。")
  246. # 更新对应的 cards 的分数状态
  247. try:
  248. crud_card.update_card_scores_and_status(db_conn, card_id_to_update)
  249. logger.info(f"卡牌 {card_id_to_update} 的分数和状态已更新。")
  250. except Exception as score_update_e:
  251. logger.error(f"更新卡牌 {card_id_to_update} 分数失败: {score_update_e}")
  252. # 更新卡牌审核状态
  253. try:
  254. with db_conn.cursor() as cursor:
  255. review_state = 2
  256. # 更新指定 card_id 的 review_state 字段
  257. query_update = f"UPDATE {settings.DB_CARD_TABLE_NAME} SET review_state = %s WHERE id = %s"
  258. cursor.execute(query_update, (review_state, card_id_to_update))
  259. if cursor.rowcount == 0:
  260. raise HTTPException(status_code=404, detail=f"ID为 {card_id_to_update} 的卡牌未找到。")
  261. db_conn.commit()
  262. logger.info(f"卡牌 ID {card_id_to_update} 的审核状态已成功修改为 {review_state}。")
  263. except Exception as e:
  264. db_conn.rollback()
  265. logger.error(f"修改卡牌 {id} 审核状态失败: {e}")
  266. if isinstance(e, HTTPException):
  267. raise e
  268. raise HTTPException(status_code=500, detail="修改审核状态失败,数据库操作错误。")
  269. return {
  270. "detail": f"成功更新图片ID {id} 的JSON数据",
  271. "image_type": image_type,
  272. "score_type": score_type
  273. }
  274. except HTTPException:
  275. db_conn.rollback()
  276. raise
  277. except Exception as e:
  278. db_conn.rollback()
  279. logger.error(f"更新JSON失败 ({id}): {e}")
  280. raise HTTPException(status_code=500, detail=f"更新JSON数据失败: {e}")
  281. finally:
  282. if cursor:
  283. cursor.close()
  284. # 处理灰度如
  285. @router.put("/update/json_gray/{id}", status_code=200, summary="[灰度] 接收xy格式, 合并至Ring图重计算并保存 [用户调用]")
  286. async def update_gray_image_json(
  287. id: int,
  288. new_json_data: dict = Body(..., description="前端传来的灰度图编辑后的JSON(xy格式)"),
  289. current_user: dict = Depends(get_current_user),
  290. db_conn: PooledMySQLConnection = db_dependency
  291. ):
  292. """
  293. 针对灰度图 (front_gray/back_gray) 的保存逻辑。
  294. """
  295. cursor = None
  296. # 1. 格式还原
  297. internal_gray_json = convert_xy_to_internal_format(new_json_data)
  298. gray_defects = internal_gray_json.get("result", {}).get("defect_result", {}).get("defects", [])
  299. try:
  300. cursor = db_conn.cursor(dictionary=True)
  301. # 2. 获取灰度图信息
  302. # 注意:灰度图存在 card_gray_images 表中
  303. cursor.execute(f"SELECT card_id, image_type FROM {settings.DB_GRAY_IMAGE_TABLE_NAME} WHERE id = %s", (id,))
  304. gray_row = cursor.fetchone()
  305. if not gray_row:
  306. raise HTTPException(status_code=404, detail=f"ID为 {id} 的灰度图未找到。")
  307. card_id = gray_row['card_id']
  308. check_card_permission(db_conn, current_user, card_id)
  309. gray_image_type = gray_row['image_type']
  310. # 3. 确定目标 Ring 图类型
  311. target_ring_type = None
  312. if gray_image_type == ImageType.front_gray.value:
  313. target_ring_type = ImageType.front_ring.value
  314. elif gray_image_type == ImageType.back_gray.value:
  315. target_ring_type = ImageType.back_ring.value
  316. else:
  317. raise HTTPException(status_code=400, detail=f"不支持的灰度图类型: {gray_image_type}")
  318. # 4. 获取目标 Ring 图数据 (Card Images 表)
  319. cursor.execute(
  320. f"SELECT id, detection_json, modified_json FROM {settings.DB_IMAGE_TABLE_NAME} "
  321. f"WHERE card_id = %s AND image_type = %s",
  322. (card_id, target_ring_type)
  323. )
  324. ring_row = cursor.fetchone()
  325. if not ring_row:
  326. raise HTTPException(status_code=404, detail=f"未找到对应的 Ring 图 ({target_ring_type}),无法应用修改。")
  327. ring_image_id = ring_row['id']
  328. # 优先使用 modified_json,如果没有则使用 detection_json
  329. source_json_str = ring_row['modified_json'] if ring_row['modified_json'] else ring_row['detection_json']
  330. if isinstance(source_json_str, str):
  331. ring_json_data = json.loads(source_json_str)
  332. else:
  333. ring_json_data = source_json_str
  334. # 5. 合并逻辑 (Merge Logic)
  335. # 确保路径存在
  336. if "result" not in ring_json_data: ring_json_data["result"] = {}
  337. if "defect_result" not in ring_json_data["result"]: ring_json_data["result"]["defect_result"] = {}
  338. if "defects" not in ring_json_data["result"]["defect_result"]: ring_json_data["result"]["defect_result"][
  339. "defects"] = []
  340. ring_defects = ring_json_data["result"]["defect_result"]["defects"]
  341. # 遍历灰度图传来的新缺陷列表
  342. for new_defect in gray_defects:
  343. gray_id = new_defect.get("gray_id")
  344. # 只有带有 gray_id 的才进行特殊合并处理 (理论上前端编辑的都应该有,或者新生成的)
  345. # 如果没有 gray_id,视作普通新缺陷直接添加
  346. if not gray_id:
  347. ring_defects.append(new_defect)
  348. continue
  349. # 在 Ring 图现有的缺陷中寻找匹配的 gray_id
  350. match_index = -1
  351. for i, old_defect in enumerate(ring_defects):
  352. if old_defect.get("gray_id") == gray_id:
  353. match_index = i
  354. break
  355. if match_index != -1:
  356. # 存在:替换 (Replace)
  357. ring_defects[match_index] = new_defect
  358. else:
  359. # 不存在:添加 (Append)
  360. ring_defects.append(new_defect)
  361. # 6. 调用计算服务 (对 Ring 图数据进行重算)
  362. score_type = IMAGE_TYPE_TO_SCORE_TYPE.get(target_ring_type) # e.g., 'front_ring'
  363. logger.info(f"开始重计算 Ring 图分数 (GrayID: {id} -> RingID: {ring_image_id}, Type: {score_type})")
  364. try:
  365. response = await run_in_threadpool(
  366. lambda: requests.post(
  367. settings.SCORE_RECALCULATE_ENDPOINT,
  368. params={"score_type": score_type},
  369. json=ring_json_data, # 发送合并后的 Ring 数据
  370. timeout=20
  371. )
  372. )
  373. except Exception as e:
  374. raise HTTPException(status_code=500, detail=f"调用分数计算服务失败: {e}")
  375. if response.status_code != 200:
  376. logger.error(f"分数计算接口返回错误: {response.text}")
  377. raise HTTPException(status_code=response.status_code,
  378. detail=f"分数计算接口返回错误: {response.text}")
  379. final_ring_json = response.json()
  380. # 7. 保存结果到数据库 (保存到 Ring 图记录)
  381. final_json_str = json.dumps(final_ring_json, ensure_ascii=False)
  382. update_query = (
  383. f"UPDATE {settings.DB_IMAGE_TABLE_NAME} "
  384. f"SET modified_json = %s, is_edited = TRUE "
  385. f"WHERE id = %s"
  386. )
  387. cursor.execute(update_query, (final_json_str, ring_image_id))
  388. db_conn.commit()
  389. logger.info(f"Ring 图 {ring_image_id} 已根据灰度图 {id} 的修改进行了更新。")
  390. # 8. 更新卡牌总分状态
  391. try:
  392. crud_card.update_card_scores_and_status(db_conn, card_id)
  393. except Exception as e:
  394. logger.error(f"更新卡牌 {card_id} 分数状态失败: {e}")
  395. # 更新卡牌审核状态
  396. try:
  397. with db_conn.cursor() as cursor:
  398. review_state = 2
  399. # 更新指定 card_id 的 review_state 字段
  400. query_update = f"UPDATE {settings.DB_CARD_TABLE_NAME} SET review_state = %s WHERE id = %s"
  401. cursor.execute(query_update, (review_state, card_id))
  402. if cursor.rowcount == 0:
  403. raise HTTPException(status_code=404, detail=f"ID为 {card_id} 的卡牌未找到。")
  404. db_conn.commit()
  405. logger.info(f"卡牌 ID {card_id} 的审核状态已成功修改为 {review_state}。")
  406. except Exception as e:
  407. db_conn.rollback()
  408. logger.error(f"修改卡牌 {id} 审核状态失败: {e}")
  409. if isinstance(e, HTTPException):
  410. raise e
  411. raise HTTPException(status_code=500, detail="修改审核状态失败,数据库操作错误。")
  412. return {
  413. "detail": f"成功应用灰度图修改到 {target_ring_type}",
  414. "target_ring_id": ring_image_id,
  415. "gray_id": id
  416. }
  417. except HTTPException:
  418. db_conn.rollback()
  419. raise
  420. except Exception as e:
  421. db_conn.rollback()
  422. logger.error(f"灰度图更新失败 ({id}): {e}")
  423. raise HTTPException(status_code=500, detail=f"系统内部错误: {e}")
  424. finally:
  425. if cursor:
  426. cursor.close()