rating_report.py 15 KB

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