formate_xy.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  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.utils.scheme import (
  11. CardDetailResponse, IMAGE_TYPE_TO_SCORE_TYPE, ImageType
  12. )
  13. from app.crud import crud_card
  14. from app.utils.xy_process import convert_internal_to_xy_format, convert_xy_to_internal_format
  15. logger = get_logger(__name__)
  16. router = APIRouter()
  17. db_dependency = Depends(get_db_connection)
  18. class QueryMode(str, Enum):
  19. current = "current"
  20. next = "next"
  21. prev = "prev"
  22. def _process_images_to_xy_format(card_data: dict):
  23. """
  24. 内部辅助函数:遍历卡牌数据中的图片,将 JSON 格式转换为前端需要的 XY 格式。
  25. 直接修改传入的 card_data 字典。
  26. """
  27. if "images" in card_data and card_data["images"]:
  28. for img in card_data["images"]:
  29. # 处理 detection_json
  30. if img.detection_json:
  31. d_json = img.detection_json
  32. if isinstance(d_json, str):
  33. d_json = json.loads(d_json)
  34. # *** 转换逻辑 ***
  35. img.detection_json = convert_internal_to_xy_format(d_json)
  36. # 处理 modified_json
  37. if img.modified_json:
  38. m_json = img.modified_json
  39. if isinstance(m_json, str):
  40. m_json = json.loads(m_json)
  41. # *** 转换逻辑 ***
  42. img.modified_json = convert_internal_to_xy_format(m_json)
  43. return card_data
  44. @router.get("/query", response_model=CardDetailResponse, summary="获取卡牌详细信息(格式化xy), 支持前后翻页")
  45. def get_card_details(
  46. card_id: int = Query(..., description="基准卡牌ID"),
  47. mode: QueryMode = Query(QueryMode.current, description="查询模式: current(当前), next(下一个), prev(上一个)"),
  48. db_conn: PooledMySQLConnection = db_dependency
  49. ):
  50. """
  51. 获取卡牌元数据以及所有与之关联的图片信息,并将坐标转换为 xy 格式。
  52. 同时返回上一张和下一张卡牌的ID。
  53. - **current**: 查询 card_id 对应的卡牌。
  54. - **next**: 查询 ID 比 card_id 大的第一张卡牌。
  55. - **prev**: 查询 ID 比 card_id 小的第一张卡牌。
  56. """
  57. target_id = card_id
  58. cursor = None
  59. try:
  60. cursor = db_conn.cursor(dictionary=True)
  61. # 1. 如果是查询上一个或下一个,先计算目标ID
  62. if mode != QueryMode.current:
  63. if mode == QueryMode.next:
  64. query_target = (f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} "
  65. f"WHERE id > %s ORDER BY id ASC LIMIT 1")
  66. else: # mode == QueryMode.prev
  67. query_target = (f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} "
  68. f"WHERE id < %s ORDER BY id DESC LIMIT 1")
  69. cursor.execute(query_target, (card_id,))
  70. row = cursor.fetchone()
  71. if not row:
  72. msg = "没有下一张" if mode == QueryMode.next else "没有上一张"
  73. raise HTTPException(status_code=200, detail=msg)
  74. target_id = row['id']
  75. # 2. 获取目标卡牌的详细数据 (Dict 格式)
  76. card_data = crud_card.get_card_with_details(db_conn, target_id)
  77. if not card_data:
  78. raise HTTPException(status_code=404, detail=f"ID为 {target_id} 的卡牌未找到。")
  79. # 3. 补充当前目标卡牌的 id_prev 和 id_next
  80. # 注意:这里需要重新获取 cursor,或者使用 cursor (非 dict 模式可能更方便取值,但 dict 模式也行)
  81. # 这里为了简单直接用 raw SQL
  82. # 查询上一个ID
  83. sql_prev = f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} WHERE id < %s ORDER BY id DESC LIMIT 1"
  84. cursor.execute(sql_prev, (target_id,))
  85. row_prev = cursor.fetchone()
  86. card_data['id_prev'] = row_prev['id'] if row_prev else None
  87. # 查询下一个ID
  88. sql_next = f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} WHERE id > %s ORDER BY id ASC LIMIT 1"
  89. cursor.execute(sql_next, (target_id,))
  90. row_next = cursor.fetchone()
  91. card_data['id_next'] = row_next['id'] if row_next else None
  92. # 4. 遍历图片,转换格式 (使用抽取出的辅助函数)
  93. _process_images_to_xy_format(card_data)
  94. # 5. 验证并返回
  95. return CardDetailResponse.model_validate(card_data)
  96. except HTTPException:
  97. raise
  98. except Exception as e:
  99. logger.error(f"查询卡牌详情失败 (Mode: {mode}, BaseID: {card_id}): {e}")
  100. raise HTTPException(status_code=500, detail="数据库查询失败")
  101. finally:
  102. if cursor:
  103. cursor.close()
  104. @router.put("/update/json/{id}", status_code=200, summary="接收xy格式, 还原后重计算分数并保存")
  105. async def update_image_modified_json(
  106. id: int,
  107. new_json_data: dict = Body(..., description="前端传来的包含xy对象格式的JSON"),
  108. db_conn: PooledMySQLConnection = db_dependency
  109. ):
  110. """
  111. 接收前端传来的特殊格式 JSON (points 为对象列表)。
  112. 1. 将格式还原为后端标准格式 (points 为 [[x,y]])。
  113. 2. 根据 id 获取 image_type。
  114. 3. 调用外部接口重新计算分数。
  115. 4. 更新 modified_json。
  116. """
  117. card_id_to_update = None
  118. cursor = None
  119. # *** 1. 格式还原 ***
  120. # 将前端的 xy dict 格式转回 [[x,y]],并丢弃 points 里的 id
  121. internal_json_payload = convert_xy_to_internal_format(new_json_data)
  122. try:
  123. cursor = db_conn.cursor(dictionary=True)
  124. # 2. 获取图片信息
  125. cursor.execute(f"SELECT image_type, card_id FROM {settings.DB_IMAGE_TABLE_NAME} WHERE id = %s", (id,))
  126. row = cursor.fetchone()
  127. if not row:
  128. raise HTTPException(status_code=404, detail=f"ID为 {id} 的图片未找到。")
  129. card_id_to_update = row["card_id"]
  130. image_type = row["image_type"]
  131. score_type = IMAGE_TYPE_TO_SCORE_TYPE.get(image_type)
  132. if not score_type:
  133. raise HTTPException(status_code=400, detail=f"未知的 image_type: {image_type}")
  134. logger.info(f"开始计算分数 (ID: {id}, Type: {score_type})")
  135. # 3. 调用远程计算接口 (使用还原后的 JSON)
  136. try:
  137. response = await run_in_threadpool(
  138. lambda: requests.post(
  139. settings.SCORE_RECALCULATE_ENDPOINT,
  140. params={"score_type": score_type},
  141. json=internal_json_payload, # 传递还原后的数据
  142. timeout=20
  143. )
  144. )
  145. except Exception as e:
  146. raise HTTPException(status_code=500, detail=f"调用分数计算服务失败: {e}")
  147. if response.status_code != 200:
  148. logger.error(f"分数计算接口返回错误: {response.status_code}, {response.text}")
  149. raise HTTPException(status_code=response.status_code,
  150. detail=f"分数计算接口返回错误: {response.text}")
  151. logger.info("分数计算完成")
  152. # 获取计算服务返回的结果(这个结果通常已经是标准的 internal 格式,带有分数和面积)
  153. final_json_data = response.json()
  154. # 4. 保存结果到数据库
  155. recalculated_json_str = json.dumps(final_json_data, ensure_ascii=False)
  156. update_query = (f"UPDATE {settings.DB_IMAGE_TABLE_NAME} "
  157. f"SET modified_json = %s, is_edited = TRUE "
  158. f"WHERE id = %s")
  159. cursor.execute(update_query, (recalculated_json_str, id))
  160. db_conn.commit()
  161. logger.info(f"图片ID {id} 的 modified_json 已更新并重新计算。")
  162. # 更新对应的 cards 的分数状态
  163. try:
  164. crud_card.update_card_scores_and_status(db_conn, card_id_to_update)
  165. logger.info(f"卡牌 {card_id_to_update} 的分数和状态已更新。")
  166. except Exception as score_update_e:
  167. logger.error(f"更新卡牌 {card_id_to_update} 分数失败: {score_update_e}")
  168. return {
  169. "detail": f"成功更新图片ID {id} 的JSON数据",
  170. "image_type": image_type,
  171. "score_type": score_type
  172. }
  173. except HTTPException:
  174. db_conn.rollback()
  175. raise
  176. except Exception as e:
  177. db_conn.rollback()
  178. logger.error(f"更新JSON失败 ({id}): {e}")
  179. raise HTTPException(status_code=500, detail=f"更新JSON数据失败: {e}")
  180. finally:
  181. if cursor:
  182. cursor.close()
  183. # 处理灰度如
  184. @router.put("/update/json_gray/{id}", status_code=200, summary="[灰度] 接收xy格式, 合并至Ring图重计算并保存")
  185. async def update_gray_image_json(
  186. id: int,
  187. new_json_data: dict = Body(..., description="前端传来的灰度图编辑后的JSON(xy格式)"),
  188. db_conn: PooledMySQLConnection = db_dependency
  189. ):
  190. """
  191. 针对灰度图 (front_gray/back_gray) 的保存逻辑。
  192. """
  193. cursor = None
  194. # 1. 格式还原
  195. internal_gray_json = convert_xy_to_internal_format(new_json_data)
  196. gray_defects = internal_gray_json.get("result", {}).get("defect_result", {}).get("defects", [])
  197. try:
  198. cursor = db_conn.cursor(dictionary=True)
  199. # 2. 获取灰度图信息
  200. # 注意:灰度图存在 card_gray_images 表中
  201. cursor.execute(f"SELECT card_id, image_type FROM {settings.DB_GRAY_IMAGE_TABLE_NAME} WHERE id = %s", (id,))
  202. gray_row = cursor.fetchone()
  203. if not gray_row:
  204. raise HTTPException(status_code=404, detail=f"ID为 {id} 的灰度图未找到。")
  205. card_id = gray_row['card_id']
  206. gray_image_type = gray_row['image_type']
  207. # 3. 确定目标 Ring 图类型
  208. target_ring_type = None
  209. if gray_image_type == ImageType.front_gray.value:
  210. target_ring_type = ImageType.front_ring.value
  211. elif gray_image_type == ImageType.back_gray.value:
  212. target_ring_type = ImageType.back_ring.value
  213. else:
  214. raise HTTPException(status_code=400, detail=f"不支持的灰度图类型: {gray_image_type}")
  215. # 4. 获取目标 Ring 图数据 (Card Images 表)
  216. cursor.execute(
  217. f"SELECT id, detection_json, modified_json FROM {settings.DB_IMAGE_TABLE_NAME} "
  218. f"WHERE card_id = %s AND image_type = %s",
  219. (card_id, target_ring_type)
  220. )
  221. ring_row = cursor.fetchone()
  222. if not ring_row:
  223. raise HTTPException(status_code=404, detail=f"未找到对应的 Ring 图 ({target_ring_type}),无法应用修改。")
  224. ring_image_id = ring_row['id']
  225. # 优先使用 modified_json,如果没有则使用 detection_json
  226. source_json_str = ring_row['modified_json'] if ring_row['modified_json'] else ring_row['detection_json']
  227. if isinstance(source_json_str, str):
  228. ring_json_data = json.loads(source_json_str)
  229. else:
  230. ring_json_data = source_json_str
  231. # 5. 合并逻辑 (Merge Logic)
  232. # 确保路径存在
  233. if "result" not in ring_json_data: ring_json_data["result"] = {}
  234. if "defect_result" not in ring_json_data["result"]: ring_json_data["result"]["defect_result"] = {}
  235. if "defects" not in ring_json_data["result"]["defect_result"]: ring_json_data["result"]["defect_result"][
  236. "defects"] = []
  237. ring_defects = ring_json_data["result"]["defect_result"]["defects"]
  238. # 遍历灰度图传来的新缺陷列表
  239. for new_defect in gray_defects:
  240. gray_id = new_defect.get("gray_id")
  241. # 只有带有 gray_id 的才进行特殊合并处理 (理论上前端编辑的都应该有,或者新生成的)
  242. # 如果没有 gray_id,视作普通新缺陷直接添加
  243. if not gray_id:
  244. ring_defects.append(new_defect)
  245. continue
  246. # 在 Ring 图现有的缺陷中寻找匹配的 gray_id
  247. match_index = -1
  248. for i, old_defect in enumerate(ring_defects):
  249. if old_defect.get("gray_id") == gray_id:
  250. match_index = i
  251. break
  252. if match_index != -1:
  253. # 存在:替换 (Replace)
  254. ring_defects[match_index] = new_defect
  255. else:
  256. # 不存在:添加 (Append)
  257. ring_defects.append(new_defect)
  258. # 6. 调用计算服务 (对 Ring 图数据进行重算)
  259. score_type = IMAGE_TYPE_TO_SCORE_TYPE.get(target_ring_type) # e.g., 'front_ring'
  260. logger.info(f"开始重计算 Ring 图分数 (GrayID: {id} -> RingID: {ring_image_id}, Type: {score_type})")
  261. try:
  262. response = await run_in_threadpool(
  263. lambda: requests.post(
  264. settings.SCORE_RECALCULATE_ENDPOINT,
  265. params={"score_type": score_type},
  266. json=ring_json_data, # 发送合并后的 Ring 数据
  267. timeout=20
  268. )
  269. )
  270. except Exception as e:
  271. raise HTTPException(status_code=500, detail=f"调用分数计算服务失败: {e}")
  272. if response.status_code != 200:
  273. logger.error(f"分数计算接口返回错误: {response.text}")
  274. raise HTTPException(status_code=response.status_code,
  275. detail=f"分数计算接口返回错误: {response.text}")
  276. final_ring_json = response.json()
  277. # 7. 保存结果到数据库 (保存到 Ring 图记录)
  278. final_json_str = json.dumps(final_ring_json, ensure_ascii=False)
  279. update_query = (
  280. f"UPDATE {settings.DB_IMAGE_TABLE_NAME} "
  281. f"SET modified_json = %s, is_edited = TRUE "
  282. f"WHERE id = %s"
  283. )
  284. cursor.execute(update_query, (final_json_str, ring_image_id))
  285. db_conn.commit()
  286. logger.info(f"Ring 图 {ring_image_id} 已根据灰度图 {id} 的修改进行了更新。")
  287. # 8. 更新卡牌总分状态
  288. try:
  289. crud_card.update_card_scores_and_status(db_conn, card_id)
  290. except Exception as e:
  291. logger.error(f"更新卡牌 {card_id} 分数状态失败: {e}")
  292. return {
  293. "detail": f"成功应用灰度图修改到 {target_ring_type}",
  294. "target_ring_id": ring_image_id,
  295. "gray_id": id
  296. }
  297. except HTTPException:
  298. db_conn.rollback()
  299. raise
  300. except Exception as e:
  301. db_conn.rollback()
  302. logger.error(f"灰度图更新失败 ({id}): {e}")
  303. raise HTTPException(status_code=500, detail=f"系统内部错误: {e}")
  304. finally:
  305. if cursor:
  306. cursor.close()