rating_report.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  1. from fastapi import APIRouter, HTTPException, Depends, Query
  2. from mysql.connector.pooling import PooledMySQLConnection
  3. import io
  4. import json
  5. from datetime import datetime
  6. from app.core.minio_client import minio_client
  7. from typing import List, Dict, Any, Optional
  8. from PIL import Image
  9. from app.core.logger import get_logger
  10. from app.core.config import settings
  11. from app.core.database_loader import get_db_connection
  12. from app.crud import crud_card
  13. from app.utils.scheme import ImageType
  14. logger = get_logger(__name__)
  15. router = APIRouter()
  16. def _parse_json_value(raw_value: Any) -> Dict[str, Any]:
  17. if raw_value is None:
  18. return {}
  19. if isinstance(raw_value, dict):
  20. return raw_value
  21. if isinstance(raw_value, str):
  22. try:
  23. return json.loads(raw_value)
  24. except json.JSONDecodeError:
  25. return {}
  26. return {}
  27. def _format_datetime(dt: Any) -> str:
  28. if isinstance(dt, datetime):
  29. return dt.strftime("%Y-%m-%d %H:%M:%S")
  30. return ""
  31. def _save_rating_report_history(
  32. db_conn: PooledMySQLConnection,
  33. card_id: int,
  34. card_no: str,
  35. report_name: str,
  36. report_json: Dict[str, Any]
  37. ) -> int:
  38. try:
  39. with db_conn.cursor() as cursor:
  40. insert_sql = (
  41. f"INSERT INTO {settings.RATING_REPORT_HISTORY_TABLE_NAME} "
  42. "(card_id, cardNo, report_name, report_json) "
  43. "VALUES (%s, %s, %s, %s)"
  44. )
  45. cursor.execute(
  46. insert_sql,
  47. (card_id, card_no, report_name, json.dumps(report_json, ensure_ascii=False))
  48. )
  49. db_conn.commit()
  50. return cursor.lastrowid
  51. except Exception as e:
  52. db_conn.rollback()
  53. logger.error(f"保存评级报告历史失败: {e}")
  54. raise HTTPException(status_code=500, detail="保存评级报告历史失败。")
  55. def _get_active_json(image_data: Any) -> Optional[Dict]:
  56. """获取有效的json数据,优先 modified_json"""
  57. if not image_data:
  58. return None
  59. # image_data 可能是 Pydantic 对象或 字典,做兼容处理
  60. if hasattr(image_data, "modified_json"):
  61. mj = image_data.modified_json
  62. dj = image_data.detection_json
  63. else:
  64. mj = image_data.get("modified_json")
  65. dj = image_data.get("detection_json")
  66. # 注意:根据 schema.py,这里读出来已经是 dict 了,不需要 json.loads
  67. # 如果数据库里存的是 null,读出来是 None
  68. if mj:
  69. return mj
  70. return dj
  71. def _crop_defect_image(original_image_path_str: str, min_rect: List, output_filename: str) -> str:
  72. """
  73. 通过 MinIO 切割缺陷图片为正方形并保存
  74. """
  75. try:
  76. # ★ 将进来的全路径 URL 剥离为相对路径 (如 /Data/xxx.jpg) 供 MinIO 读取
  77. rel_path = original_image_path_str.replace(settings.DATA_HOST_URL, "")
  78. rel_path = "/" + rel_path.lstrip('/\\')
  79. object_name = f"{settings.MINIO_BASE_PREFIX}{rel_path}"
  80. # 1. 从 MinIO 获取原图字节
  81. try:
  82. response = minio_client.get_object(settings.MINIO_BUCKET, object_name)
  83. image_bytes = response.read()
  84. response.close()
  85. response.release_conn()
  86. except Exception as e:
  87. logger.warning(f"从MinIO获取原图失败: {object_name} -> {e}")
  88. return ""
  89. # 2. 在内存中用 PIL 切图
  90. with Image.open(io.BytesIO(image_bytes)) as img:
  91. img_w, img_h = img.size
  92. center_x, center_y = min_rect[0]
  93. rect_w, rect_h = min_rect[1]
  94. side_length = max(max(rect_w, rect_h) * 1.5, 100)
  95. half_side = side_length / 2
  96. left, top = max(0, center_x - half_side), max(0, center_y - half_side)
  97. right, bottom = min(img_w, center_x + half_side), min(img_h, center_y + half_side)
  98. cropped_img = img.crop((left, top, right, bottom))
  99. # 3. 将切割后的图存入内存流,并上传到 MinIO
  100. out_bytes = io.BytesIO()
  101. cropped_img.save(out_bytes, format="JPEG", quality=95)
  102. out_bytes.seek(0)
  103. out_rel_path = f"/DefectImage/{output_filename}"
  104. out_object_name = f"{settings.MINIO_BASE_PREFIX}{out_rel_path}"
  105. minio_client.put_object(
  106. settings.MINIO_BUCKET,
  107. out_object_name,
  108. out_bytes,
  109. len(out_bytes.getvalue()),
  110. content_type="image/jpeg"
  111. )
  112. return settings.get_full_url(out_rel_path)
  113. except Exception as e:
  114. logger.error(f"切割并上传图片失败: {e}")
  115. return ""
  116. @router.get("/generate", status_code=200, summary="生成评级报告数据")
  117. def generate_rating_report(
  118. cardNo: str,
  119. db_conn: PooledMySQLConnection = Depends(get_db_connection)
  120. ):
  121. if not cardNo or not cardNo.strip():
  122. raise HTTPException(status_code=400, detail="cardNo 不能为空")
  123. # 根据cardNo 查询id
  124. try:
  125. with db_conn.cursor(buffered=True) as cursor:
  126. query_sql = f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} WHERE cardNo = %s LIMIT 1"
  127. cursor.execute(query_sql, (cardNo,))
  128. row = cursor.fetchone()
  129. except Exception as e:
  130. logger.error(f"创建卡牌失败: {e}")
  131. raise HTTPException(status_code=500, detail="数据库查询失败。")
  132. if not row:
  133. raise HTTPException(
  134. status_code=404,
  135. detail=f"未找到卡牌编号为 {cardNo} 的相关记录"
  136. )
  137. card_id = row[0]
  138. rating_time_now = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
  139. # top_n_defects = 3
  140. """
  141. 根据 Card ID 生成评级报告 JSON
  142. """
  143. # 1. 获取卡片详情 (复用 Crud 逻辑,确保能拿到所有图片)
  144. card_data = crud_card.get_card_with_details(db_conn, card_id)
  145. if not card_data:
  146. raise HTTPException(status_code=404, detail="未找到该卡片信息")
  147. # 初始化返回结构
  148. response_data = {
  149. "backImageUrl": "",
  150. "frontImageUrl": "",
  151. "cardNo": cardNo,
  152. "centerBack": "",
  153. "centerFront": "",
  154. "measureLength": 0.0,
  155. "measureWidth": 0.0,
  156. "cornerBackNum": 0,
  157. "sideBackNum": 0,
  158. "surfaceBackNum": 0,
  159. "cornerFrontNum": 0,
  160. "sideFrontNum": 0,
  161. "surfaceFrontNum": 0,
  162. "scoreThreshold": float(card_data.get("modified_score") or 0),
  163. "evaluateNo": str(card_data.get("id")),
  164. "defectDetailList": []
  165. }
  166. # 临时列表用于收集所有缺陷,最后排序取 Top N
  167. all_defects_collected = []
  168. # 遍历图片寻找 Front Ring 和 Back Ring
  169. images = card_data.get("images", [])
  170. # 辅助字典:defect_type 到 统计字段 的映射
  171. defect_map_keys = {
  172. "front_ring": {
  173. "corner": "cornerFrontNum",
  174. "edge": "sideFrontNum",
  175. "face": "surfaceFrontNum"
  176. },
  177. "back_ring": {
  178. "corner": "cornerBackNum",
  179. "edge": "sideBackNum",
  180. "face": "surfaceBackNum"
  181. }
  182. }
  183. for img in images:
  184. img_type = img.image_type
  185. # 只处理环光图
  186. if img_type not in ["front_ring", "back_ring"]:
  187. continue
  188. # 设置主图 URL
  189. if img_type == "front_ring":
  190. response_data["frontImageUrl"] = img.image_path
  191. elif img_type == "back_ring":
  192. response_data["backImageUrl"] = img.image_path
  193. # 获取有效 JSON
  194. json_data = _get_active_json(img)
  195. if not json_data or "result" not in json_data:
  196. continue
  197. result_node = json_data["result"]
  198. # 1. 处理居中 (Center)
  199. center_inf = result_node.get("center_result", {}).get("box_result", {}).get("center_inference", {})
  200. if center_inf:
  201. # 格式: L/R=47/53, T/B=51/49 (取整)
  202. # center_inference 包含 center_left, center_right, center_top, center_bottom
  203. c_str = (
  204. f"L/R={int(round(center_inf.get('center_left', 0)))}/{int(round(center_inf.get('center_right', 0)))}, "
  205. f"T/B={int(round(center_inf.get('center_top', 0)))}/{int(round(center_inf.get('center_bottom', 0)))}"
  206. )
  207. if img_type == "front_ring":
  208. response_data["centerFront"] = c_str
  209. # 2. 处理尺寸 (仅从正面取,或者只要有就取) - mm 转 cm,除以 10,保留2位
  210. rw_mm = center_inf.get("real_width_mm", 0)
  211. rh_mm = center_inf.get("real_height_mm", 0)
  212. response_data["measureWidth"] = round(rw_mm / 10.0, 2)
  213. response_data["measureLength"] = round(rh_mm / 10.0, 2)
  214. else:
  215. response_data["centerBack"] = c_str
  216. # 2. 处理缺陷 (Defects)
  217. defects = result_node.get("defect_result", {}).get("defects", [])
  218. for defect in defects:
  219. # 过滤 edit_type == 'del'
  220. if defect.get("edit_type") == "del":
  221. continue
  222. d_type = defect.get("defect_type", "") # corner, edge, face
  223. d_label = defect.get("label", "") # scratch, wear, etc.
  224. # 统计数量
  225. count_key = defect_map_keys.get(img_type, {}).get(d_type)
  226. if count_key:
  227. response_data[count_key] += 1
  228. # 收集详细信息用于 Top N 列表
  229. # 需要保存:缺陷对象本身,图片路径,正反面标识
  230. side_str = "FRONT" if img_type == "front_ring" else "BACK"
  231. all_defects_collected.append({
  232. "defect_data": defect,
  233. "image_path": img.image_path,
  234. "side": side_str,
  235. "area": defect.get("actual_area", 0)
  236. })
  237. # 3. 处理 defectDetailList (Top N 切图)
  238. # 按实际面积从大到小排序
  239. all_defects_collected.sort(key=lambda x: x["area"], reverse=True)
  240. # top_defects = all_defects_collected[:top_n_defects]
  241. top_defects = all_defects_collected
  242. final_defect_list = []
  243. for idx, item in enumerate(top_defects, start=1):
  244. defect = item["defect_data"]
  245. side = item["side"]
  246. original_img_path = item["image_path"]
  247. # 构造 ID
  248. d_id = idx # 1, 2, 3
  249. # 构造文件名: {card_id}_{seq_id}.jpg
  250. filename = f"{card_id}_{d_id}_{rating_time_now}.jpg"
  251. # 执行切图
  252. min_rect = defect.get("min_rect")
  253. defect_img_url = ""
  254. location_str = ""
  255. if min_rect and len(min_rect) == 3:
  256. # 切图并保存
  257. defect_img_url = _crop_defect_image(original_img_path, min_rect, filename)
  258. # 计算 Location (中心坐标)
  259. # min_rect[0] 是 [x, y]
  260. cx, cy = min_rect[0]
  261. location_str = f"{int(cx)},{int(cy)}"
  262. # 构造 Type 字符串: defect_type + label (大写)
  263. # 例如: defect_type="edge", label="wear" -> "EDGE WEAR"
  264. d_type_raw = defect.get("defect_type", "")
  265. # d_label_raw = defect.get("label", "")
  266. type_str = f"{d_type_raw.upper()}".strip()
  267. type_str_map = {"CORNER": "CORNER",
  268. "EDGE": "SIDE",
  269. "FACE": "SURFACE"}
  270. type_str = type_str_map.get(type_str)
  271. final_defect_list.append({
  272. "id": d_id,
  273. "side": side,
  274. "location": location_str,
  275. "type": type_str,
  276. "defectImgUrl": defect_img_url
  277. })
  278. response_data["defectDetailList"] = final_defect_list
  279. report_name = f"{cardNo}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
  280. rating_id = _save_rating_report_history(db_conn, card_id, cardNo, report_name, response_data)
  281. response_data["ratingId"] = rating_id
  282. response_data["reportName"] = report_name
  283. return response_data
  284. @router.get("/history", status_code=200, summary="根据 cardNo 查询评级报告历史列表")
  285. def get_rating_report_history_list(
  286. cardNo: str,
  287. skip: int = Query(0, ge=0),
  288. page_num: int = Query(None, ge=1),
  289. limit: int = Query(100, ge=1, le=1000),
  290. db_conn: PooledMySQLConnection = Depends(get_db_connection)
  291. ):
  292. if not cardNo or not cardNo.strip():
  293. raise HTTPException(status_code=400, detail="cardNo 不能为空")
  294. if page_num is not None:
  295. skip = (page_num - 1) * limit
  296. try:
  297. with db_conn.cursor(dictionary=True) as cursor:
  298. count_sql = (
  299. f"SELECT COUNT(*) AS total "
  300. f"FROM {settings.RATING_REPORT_HISTORY_TABLE_NAME} "
  301. "WHERE cardNo = %s"
  302. )
  303. cursor.execute(count_sql, (cardNo,))
  304. count_row = cursor.fetchone() or {}
  305. total_count = int(count_row.get("total") or 0)
  306. query_sql = (
  307. f"SELECT rating_id, card_id, cardNo, report_name, created_at "
  308. f"FROM {settings.RATING_REPORT_HISTORY_TABLE_NAME} "
  309. "WHERE cardNo = %s "
  310. "ORDER BY rating_id DESC "
  311. "LIMIT %s OFFSET %s"
  312. )
  313. cursor.execute(query_sql, (cardNo, limit, skip))
  314. rows = cursor.fetchall()
  315. except Exception as e:
  316. logger.error(f"查询评级报告历史列表失败: {e}")
  317. raise HTTPException(status_code=500, detail="查询评级报告历史列表失败。")
  318. history_list = []
  319. for row in rows:
  320. history_list.append({
  321. "ratingId": row.get("rating_id"),
  322. "cardId": row.get("card_id"),
  323. "cardNo": row.get("cardNo"),
  324. "reportName": row.get("report_name"),
  325. "createdAt": _format_datetime(row.get("created_at"))
  326. })
  327. return {
  328. "cardNo": cardNo,
  329. "data": {
  330. "total": total_count,
  331. "list": history_list
  332. }
  333. }
  334. @router.get("/history/{rating_id}", status_code=200, summary="根据 rating_id 查询单个评级报告历史")
  335. def get_rating_report_history_detail(
  336. rating_id: int,
  337. db_conn: PooledMySQLConnection = Depends(get_db_connection)
  338. ):
  339. try:
  340. with db_conn.cursor(dictionary=True) as cursor:
  341. query_sql = (
  342. f"SELECT rating_id, card_id, cardNo, report_name, report_json, created_at, updated_at "
  343. f"FROM {settings.RATING_REPORT_HISTORY_TABLE_NAME} "
  344. "WHERE rating_id = %s LIMIT 1"
  345. )
  346. cursor.execute(query_sql, (rating_id,))
  347. row = cursor.fetchone()
  348. except Exception as e:
  349. logger.error(f"查询评级报告历史详情失败: {e}")
  350. raise HTTPException(status_code=500, detail="查询评级报告历史详情失败。")
  351. if not row:
  352. raise HTTPException(status_code=404, detail=f"未找到 rating_id={rating_id} 的历史记录")
  353. return {
  354. "ratingId": row.get("rating_id"),
  355. "cardId": row.get("card_id"),
  356. "cardNo": row.get("cardNo"),
  357. "reportName": row.get("report_name"),
  358. "createdAt": _format_datetime(row.get("created_at")),
  359. "updatedAt": _format_datetime(row.get("updated_at")),
  360. "reportData": _parse_json_value(row.get("report_json"))
  361. }