images.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. import os
  2. import uuid
  3. import json
  4. import requests
  5. from typing import Optional, Dict, Any
  6. from fastapi import APIRouter, File, UploadFile, Depends, HTTPException, Form, Path
  7. from fastapi.concurrency import run_in_threadpool
  8. from fastapi.responses import JSONResponse, FileResponse
  9. from mysql.connector.pooling import PooledMySQLConnection
  10. from mysql.connector import IntegrityError
  11. from app.core.config import settings
  12. from app.core.logger import get_logger
  13. from app.utils.scheme import CardImageResponse, ImageJsonPairResponse, ResultImagePathType
  14. from app.utils.scheme import ImageType, IMAGE_TYPE_TO_SCORE_TYPE
  15. from app.core.database_loader import get_db_connection
  16. from app.crud import crud_card
  17. logger = get_logger(__name__)
  18. router = APIRouter()
  19. db_dependency = Depends(get_db_connection)
  20. @router.post("/insert/{card_id}", response_model=CardImageResponse, status_code=201,
  21. summary="为卡牌上传并关联一张图片")
  22. async def upload_image_for_card(
  23. card_id: int = Path(..., description="要关联的卡牌ID"),
  24. image_type: ImageType = Form(..., description="图片类型 (front_face, back_face, etc.)"),
  25. image: UploadFile = File(..., description="图片文件"),
  26. image_name: Optional[str] = Form(None, description="图片的可选名称"),
  27. json_data_str: str = Form(..., description="与图片关联的JSON字符串"),
  28. db_conn: PooledMySQLConnection = db_dependency
  29. ):
  30. """
  31. 上传一张图片,并将其作为一条新记录存入 card_images 表。
  32. 这是一个事务性操作,并会检查是否存在重复的 (card_id, image_type) 组合。
  33. """
  34. try:
  35. detection_json = json.loads(json_data_str)
  36. except json.JSONDecodeError:
  37. raise HTTPException(status_code=400, detail="JSON格式无效。")
  38. file_extension = os.path.splitext(image.filename)[1]
  39. unique_filename = f"{uuid.uuid4()}{file_extension}"
  40. image_path = settings.DATA_DIR / unique_filename
  41. relative_path = f"/{image_path.parent.name}/{image_path.name}"
  42. try:
  43. with open(image_path, "wb") as buffer:
  44. buffer.write(await image.read())
  45. except Exception as e:
  46. logger.error(f"保存图片失败: {e}")
  47. raise HTTPException(status_code=500, detail="无法保存图片文件。")
  48. cursor = None
  49. try:
  50. cursor = db_conn.cursor()
  51. # 检查 card_id 是否存在
  52. cursor.execute(f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} WHERE id = %s", (card_id,))
  53. if not cursor.fetchone():
  54. raise HTTPException(status_code=404, detail=f"ID为 {card_id} 的卡牌不存在。")
  55. query_insert_image = (
  56. f"INSERT INTO {settings.DB_IMAGE_TABLE_NAME} "
  57. "(card_id, image_type, image_name, image_path, detection_json) "
  58. "VALUES (%s, %s, %s, %s, %s)"
  59. )
  60. params = (
  61. card_id, image_type.value, image_name, relative_path, json.dumps(detection_json, ensure_ascii=False))
  62. cursor.execute(query_insert_image, params)
  63. new_id = cursor.lastrowid
  64. db_conn.commit()
  65. logger.info(f"图片 {new_id} 已成功关联到卡牌 {card_id},类型为 {image_type.value}。")
  66. try:
  67. crud_card.update_card_scores_and_status(db_conn, card_id)
  68. logger.info(f"卡牌 {card_id} 的分数和状态已更新。")
  69. except Exception as score_update_e:
  70. # 即使分数更新失败,图片也已经成功插入,记录错误但不回滚
  71. logger.error(f"更新卡牌 {card_id} 分数失败: {score_update_e}")
  72. cursor.execute(f"SELECT * FROM {settings.DB_IMAGE_TABLE_NAME} WHERE id = %s", (new_id,))
  73. new_image_data = cursor.fetchone()
  74. columns = [desc[0] for desc in cursor.description]
  75. return CardImageResponse.model_validate(dict(zip(columns, new_image_data)))
  76. except IntegrityError as e:
  77. db_conn.rollback()
  78. if os.path.exists(image_path): os.remove(image_path)
  79. if e.errno == 1062:
  80. raise HTTPException(
  81. status_code=409,
  82. detail=f"卡牌ID {card_id} 已存在类型为 '{image_type.value}' 的图片,请勿重复添加。"
  83. )
  84. logger.error(f"数据库完整性错误: {e}")
  85. raise HTTPException(status_code=500, detail="数据库操作失败。")
  86. except Exception as e:
  87. db_conn.rollback()
  88. if os.path.exists(image_path):
  89. os.remove(image_path)
  90. logger.error(f"关联图片到卡牌失败: {e}")
  91. if isinstance(e, HTTPException): raise e
  92. raise HTTPException(status_code=500, detail="数据库操作失败,所有更改已回滚。")
  93. finally:
  94. if cursor:
  95. cursor.close()
  96. @router.get("/jsons/{id}", response_model=ImageJsonPairResponse, summary="获取图片的原始和修改后JSON")
  97. def get_image_jsons(id: int, db_conn: PooledMySQLConnection = db_dependency):
  98. """获取指定图片ID的 detection_json 和 modified_json。"""
  99. cursor = None
  100. try:
  101. cursor = db_conn.cursor(dictionary=True)
  102. query = f"SELECT id, detection_json, modified_json FROM {settings.DB_IMAGE_TABLE_NAME} WHERE id = %s"
  103. cursor.execute(query, (id,))
  104. result = cursor.fetchone()
  105. if not result:
  106. raise HTTPException(status_code=404, detail=f"ID为 {id} 的图片未找到。")
  107. return ImageJsonPairResponse.model_validate(result)
  108. except Exception as e:
  109. logger.error(f"获取JSON对失败 ({id}): {e}")
  110. if isinstance(e, HTTPException): raise e
  111. raise HTTPException(status_code=500, detail="数据库查询失败。")
  112. finally:
  113. if cursor:
  114. cursor.close()
  115. @router.put("/update/json/{id}", status_code=200, summary="重新计算分数, 并修改图片的 modified_json")
  116. async def update_image_modified_json(
  117. id: int,
  118. new_json_data: dict,
  119. db_conn: PooledMySQLConnection = db_dependency
  120. ):
  121. """
  122. 根据 id 获取 image_type, 调用外部接口重新计算分数, 并更新 modified_json。
  123. updated_at 会自动更新
  124. """
  125. card_id_to_update = None
  126. cursor = None
  127. try:
  128. cursor = db_conn.cursor(dictionary=True)
  129. # 1️ 获取图片信息
  130. cursor.execute(f"SELECT image_type, card_id FROM {settings.DB_IMAGE_TABLE_NAME} WHERE id = %s", (id,))
  131. row = cursor.fetchone()
  132. if not row:
  133. raise HTTPException(status_code=404, detail=f"ID为 {id} 的图片未找到。")
  134. card_id_to_update = row["card_id"]
  135. image_type = row["image_type"]
  136. score_type = IMAGE_TYPE_TO_SCORE_TYPE.get(image_type)
  137. if not score_type:
  138. raise HTTPException(status_code=400, detail=f"未知的 image_type: {image_type}")
  139. logger.info("开始计算分数")
  140. # 2️ 调用远程计算接口
  141. try:
  142. response = await run_in_threadpool(
  143. lambda: requests.post(
  144. settings.SCORE_RECALCULATE_ENDPOINT,
  145. params={"score_type": score_type}, json=new_json_data, timeout=20
  146. )
  147. )
  148. except Exception as e:
  149. raise HTTPException(status_code=500, detail=f"调用分数计算服务失败: {e}")
  150. if response.status_code != 200:
  151. logger.error(f"分数计算接口返回错误: {response.status_code}, {response.text}")
  152. raise HTTPException(status_code=response.status_code,
  153. detail=f"分数计算接口返回错误: {response.text}")
  154. logger.info("分数计算完成")
  155. # 3️ 保存结果到数据库
  156. recalculated_json_str = json.dumps(response.json(), ensure_ascii=False)
  157. update_query = (f"UPDATE {settings.DB_IMAGE_TABLE_NAME} "
  158. f"SET modified_json = %s, is_edited = TRUE "
  159. f"WHERE id = %s")
  160. cursor.execute(update_query, (recalculated_json_str, id))
  161. logger.info(f"更新 id={id} 的操作影响了 {cursor.rowcount} 行。")
  162. if cursor.rowcount == 0:
  163. logger.warning(f"更新 id={id} 的 modified_json 未影响任何行。")
  164. db_conn.commit()
  165. logger.info(f"图片ID {id} 的 modified_json 已更新并重新计算。")
  166. # 更新对应的 cards 的分数状态
  167. try:
  168. crud_card.update_card_scores_and_status(db_conn, card_id_to_update)
  169. logger.info(f"卡牌 {card_id_to_update} 的分数和状态已更新。")
  170. except Exception as score_update_e:
  171. logger.error(f"更新卡牌 {card_id_to_update} 分数失败: {score_update_e}")
  172. return {
  173. "message": f"成功更新图片ID {id} 的JSON数据",
  174. "image_type": image_type,
  175. "score_type": score_type
  176. }
  177. except HTTPException:
  178. db_conn.rollback()
  179. raise
  180. except Exception as e:
  181. db_conn.rollback()
  182. logger.error(f"更新JSON失败 ({id}): {e}")
  183. raise HTTPException(status_code=500, detail=f"更新JSON数据失败: {e}")
  184. finally:
  185. if cursor:
  186. cursor.close()
  187. @router.put("/update/annotated/{id}", status_code=200, summary="为指定图片记录上传绘制后的结果图")
  188. async def upload_result_image(
  189. id: int = Path(..., description="要更新的图片记录ID"),
  190. path_type: ResultImagePathType = Form(...,
  191. description="要更新的路径类型 ('detection' 或 'modified')"),
  192. image: UploadFile = File(..., description="结果图片文件"),
  193. db_conn: PooledMySQLConnection = db_dependency
  194. ):
  195. """
  196. 为一条现有的 card_images 记录上传 detection_image_path 或 modified_image_path 对应的图片。
  197. 如果该字段已有图片,旧的图片文件将被删除。
  198. """
  199. # 1. 保存新图片文件
  200. file_extension = os.path.splitext(image.filename)[1]
  201. unique_filename = f"{uuid.uuid4()}{file_extension}"
  202. new_image_path = settings.DATA_DIR / unique_filename
  203. relative_path = f"/{new_image_path.parent.name}/{new_image_path.name}"
  204. try:
  205. with open(new_image_path, "wb") as buffer:
  206. buffer.write(await image.read())
  207. except Exception as e:
  208. logger.error(f"保存结果图片失败: {e}")
  209. raise HTTPException(status_code=500, detail="无法保存图片文件。")
  210. cursor = None
  211. try:
  212. cursor = db_conn.cursor(dictionary=True)
  213. # 2. 确定要更新的字段名
  214. column_to_update = f"{path_type.value}_image_path"
  215. # 3. 查询旧的图片路径,以便稍后删除
  216. cursor.execute(f"SELECT {column_to_update} FROM {settings.DB_IMAGE_TABLE_NAME} WHERE id = %s", (id,))
  217. record = cursor.fetchone()
  218. if not record:
  219. os.remove(new_image_path) # 如果记录不存在,删除刚上传的文件
  220. raise HTTPException(status_code=404, detail=f"ID为 {id} 的图片记录未找到。")
  221. old_path = record.get(column_to_update)
  222. # 4. 更新数据库
  223. update_query = f"UPDATE {settings.DB_IMAGE_TABLE_NAME} SET {column_to_update} = %s WHERE id = %s"
  224. cursor.execute(update_query, (relative_path, id))
  225. db_conn.commit()
  226. logger.info(f"图片记录 {id} 的 {column_to_update} 已更新为 {relative_path}")
  227. # 5. 删除旧的物理文件(如果存在)
  228. if old_path:
  229. absolute_old_path = settings.BASE_PATH / old_path.lstrip('/\\')
  230. if os.path.exists(absolute_old_path):
  231. try:
  232. os.remove(absolute_old_path)
  233. logger.info(f"旧的结果图片文件已删除: {absolute_old_path}")
  234. except OSError as e:
  235. logger.error(f"删除旧文件失败 {absolute_old_path}: {e}")
  236. # 6. 返回更新后的路径
  237. return {
  238. "message": f"成功更新图片ID {id} 的结果图",
  239. column_to_update: relative_path
  240. }
  241. except Exception as e:
  242. db_conn.rollback()
  243. # 如果发生任何错误,都要删除刚刚上传的新文件
  244. if os.path.exists(new_image_path):
  245. os.remove(new_image_path)
  246. logger.error(f"更新结果图片路径失败 (id={id}): {e}")
  247. if isinstance(e, HTTPException): raise e
  248. raise HTTPException(status_code=500, detail="数据库操作失败,更改已回滚。")
  249. finally:
  250. if cursor:
  251. cursor.close()
  252. @router.get("/image_file/{id}", summary="获取指定ID的图片文件")
  253. def get_image_file(id: int, db_conn: PooledMySQLConnection = db_dependency):
  254. """根据 id 查找记录,并返回对应的图片文件。"""
  255. cursor = None
  256. try:
  257. cursor = db_conn.cursor()
  258. query = f"SELECT image_path FROM {settings.DB_IMAGE_TABLE_NAME} WHERE id = %s"
  259. cursor.execute(query, (id,))
  260. result = cursor.fetchone()
  261. if not result:
  262. raise HTTPException(status_code=404, detail=f"ID为 {id} 的记录未找到。")
  263. image_path = result[0]
  264. absolute_path = settings.BASE_PATH / image_path.lstrip('/\\')
  265. if not os.path.exists(absolute_path):
  266. logger.error(f"文件在服务器上未找到: {absolute_path} (数据库ID: {id})")
  267. raise HTTPException(status_code=404, detail="图片文件在服务器上不存在。")
  268. return FileResponse(absolute_path)
  269. except Exception as e:
  270. logger.error(f"获取图片失败 ({id}): {e}")
  271. if isinstance(e, HTTPException): raise e
  272. raise HTTPException(status_code=500, detail="获取图片文件失败。")
  273. finally:
  274. if cursor:
  275. cursor.close()