formate_xy.py 42 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025
  1. import requests
  2. import json
  3. import copy
  4. import hashlib
  5. from enum import Enum
  6. from time import perf_counter
  7. from fastapi import APIRouter, Depends, HTTPException, Query, Body
  8. from fastapi.concurrency import run_in_threadpool
  9. from mysql.connector.pooling import PooledMySQLConnection
  10. from app.core.config import settings
  11. from app.core.logger import get_logger
  12. from app.core.database_loader import get_db_connection
  13. from app.api.users import check_card_permission, get_current_user
  14. from app.utils.scheme import (
  15. CardDetailResponse, ImageType
  16. )
  17. from app.crud import crud_card
  18. from app.utils.xy_process import convert_internal_to_xy_format, convert_xy_to_internal_format
  19. from app.core.minio_client import minio_client
  20. from app.utils.rating_report_utils import crop_defect_image
  21. logger = get_logger(__name__)
  22. router = APIRouter()
  23. db_dependency = Depends(get_db_connection)
  24. class QueryMode(str, Enum):
  25. current = "current"
  26. next = "next"
  27. prev = "prev"
  28. def _resolve_recalc_score_type(image_type: str):
  29. """
  30. 将 14 类新版 image_type 归一到 score_recalculate 接口接受的 score_type。
  31. 新版 stitch 导入后,同一面的 fusion/ring/stripe 共用该面 JSON;
  32. 编辑重算时按正反面统一归到 front_ring / back_ring。
  33. """
  34. image_type_to_recalc_score_type = {
  35. ImageType.front_fusion.value: ImageType.front_ring.value,
  36. ImageType.front_ring.value: ImageType.front_ring.value,
  37. ImageType.front_gray.value: ImageType.front_ring.value,
  38. ImageType.front_stripe1.value: ImageType.front_ring.value,
  39. ImageType.front_stripe2.value: ImageType.front_ring.value,
  40. ImageType.front_stripe3.value: ImageType.front_ring.value,
  41. ImageType.front_stripe4.value: ImageType.front_ring.value,
  42. ImageType.back_fusion.value: ImageType.back_ring.value,
  43. ImageType.back_ring.value: ImageType.back_ring.value,
  44. ImageType.back_gray.value: ImageType.back_ring.value,
  45. ImageType.back_stripe1.value: ImageType.back_ring.value,
  46. ImageType.back_stripe2.value: ImageType.back_ring.value,
  47. ImageType.back_stripe3.value: ImageType.back_ring.value,
  48. ImageType.back_stripe4.value: ImageType.back_ring.value,
  49. # 兼容历史同轴光数据
  50. ImageType.front_coaxial.value: ImageType.front_coaxial.value,
  51. ImageType.back_coaxial.value: ImageType.back_coaxial.value,
  52. }
  53. return image_type_to_recalc_score_type.get(image_type)
  54. def _is_center_box_shapes_empty(center_result: dict) -> bool:
  55. """
  56. 判断 center_result 中 inner/outer box 的 shapes 是否都为空。
  57. 前端某些场景会传空 shapes,直接下发给算分服务可能触发其内部越界。
  58. """
  59. if not isinstance(center_result, dict):
  60. return True
  61. box_result = center_result.get("box_result", {})
  62. if not isinstance(box_result, dict):
  63. return True
  64. inner_shapes = box_result.get("inner_box", {}).get("shapes", [])
  65. outer_shapes = box_result.get("outer_box", {}).get("shapes", [])
  66. return not inner_shapes and not outer_shapes
  67. def _normalize_center_result(center_result: dict) -> dict:
  68. """
  69. 兜底补齐算分服务依赖的 center_result 结构,避免 KeyError: 'box_result'。
  70. """
  71. normalized = center_result if isinstance(center_result, dict) else {}
  72. box_result = normalized.get("box_result")
  73. if not isinstance(box_result, dict):
  74. box_result = {}
  75. normalized["box_result"] = box_result
  76. inner_box = box_result.get("inner_box")
  77. if not isinstance(inner_box, dict):
  78. inner_box = {}
  79. box_result["inner_box"] = inner_box
  80. if not isinstance(inner_box.get("shapes"), list):
  81. inner_box["shapes"] = []
  82. outer_box = box_result.get("outer_box")
  83. if not isinstance(outer_box, dict):
  84. outer_box = {}
  85. box_result["outer_box"] = outer_box
  86. if not isinstance(outer_box.get("shapes"), list):
  87. outer_box["shapes"] = []
  88. return normalized
  89. def _prepare_recalculate_payload(edited_json: dict, source_json: dict) -> dict:
  90. """
  91. 以数据库里的原始 JSON 为底稿,合并前端编辑结果,得到更稳定的重算入参。
  92. 当前仅明确覆盖 defects;center_result 只有在前端传了非空 shapes 时才覆盖。
  93. 对前端标记为删除(edit_type=del)的缺陷,在这里直接过滤,避免重算后再次写回 modified_json。
  94. """
  95. base = copy.deepcopy(source_json) if isinstance(source_json, dict) else {}
  96. incoming = edited_json if isinstance(edited_json, dict) else {}
  97. if "id" in incoming:
  98. base["id"] = incoming["id"]
  99. if "imageWidth" in incoming:
  100. base["imageWidth"] = incoming["imageWidth"]
  101. if "imageHeight" in incoming:
  102. base["imageHeight"] = incoming["imageHeight"]
  103. base.setdefault("result", {})
  104. incoming_result = incoming.get("result", {})
  105. if not isinstance(incoming_result, dict):
  106. incoming_result = {}
  107. # defects 使用前端编辑结果覆盖;前端标记删除的项不参与重算,也不写回 modified_json
  108. incoming_defects = (
  109. incoming_result.get("defect_result", {}).get("defects", [])
  110. if isinstance(incoming_result.get("defect_result", {}), dict)
  111. else []
  112. )
  113. base["result"].setdefault("defect_result", {})
  114. filtered_defects = []
  115. if isinstance(incoming_defects, list):
  116. filtered_defects = [
  117. defect for defect in incoming_defects
  118. if not (isinstance(defect, dict) and defect.get("edit_type") == "del")
  119. ]
  120. base["result"]["defect_result"]["defects"] = filtered_defects
  121. # center_result 仅在前端有有效 shapes 时覆盖;否则沿用底稿
  122. incoming_center = incoming_result.get("center_result")
  123. if isinstance(incoming_center, dict) and not _is_center_box_shapes_empty(incoming_center):
  124. base["result"]["center_result"] = incoming_center
  125. elif "center_result" not in base["result"]:
  126. base["result"]["center_result"] = incoming_center if isinstance(incoming_center, dict) else {}
  127. base["result"]["center_result"] = _normalize_center_result(base["result"].get("center_result"))
  128. return base
  129. _GRAY_IMAGE_TYPES = frozenset({
  130. ImageType.front_gray.value,
  131. ImageType.back_gray.value,
  132. })
  133. # 以融合图 JSON 中的缺陷为准,在同面下列类型原图上裁图(不含灰度图)
  134. _FRONT_DEFECT_URL_TARGET_TYPES = [
  135. ImageType.front_fusion.value,
  136. ImageType.front_ring.value,
  137. ImageType.front_stripe1.value,
  138. ImageType.front_stripe2.value,
  139. ImageType.front_stripe3.value,
  140. ImageType.front_stripe4.value,
  141. ImageType.front_coaxial.value, # 兼容历史同轴光
  142. ]
  143. _BACK_DEFECT_URL_TARGET_TYPES = [
  144. ImageType.back_fusion.value,
  145. ImageType.back_ring.value,
  146. ImageType.back_stripe1.value,
  147. ImageType.back_stripe2.value,
  148. ImageType.back_stripe3.value,
  149. ImageType.back_stripe4.value,
  150. ImageType.back_coaxial.value,
  151. ]
  152. _DEFECT_URL_TARGET_TYPES_BY_SIDE = {
  153. "front": _FRONT_DEFECT_URL_TARGET_TYPES,
  154. "back": _BACK_DEFECT_URL_TARGET_TYPES,
  155. }
  156. # ring / stripe 等可能落在 card_gray_images,裁图时需与主表合并
  157. _ALL_DEFECT_URL_TARGET_TYPES = frozenset(
  158. _FRONT_DEFECT_URL_TARGET_TYPES + _BACK_DEFECT_URL_TARGET_TYPES
  159. )
  160. def _is_gray_image_type(image_type: str) -> bool:
  161. return image_type in _GRAY_IMAGE_TYPES
  162. def _side_key_from_image_type(image_type: str) -> str:
  163. if image_type.startswith("front_"):
  164. return "front"
  165. if image_type.startswith("back_"):
  166. return "back"
  167. return ""
  168. def _defect_rect_hash(min_rect) -> str:
  169. if not min_rect or len(min_rect) != 3:
  170. return ""
  171. rect_str = str(min_rect)
  172. return hashlib.md5(rect_str.encode("utf-8")).hexdigest()[:8]
  173. def _resolve_fusion_images_by_side(all_images: list) -> dict:
  174. """每面仅以融合图 JSON 作为缺陷与裁图坐标来源。"""
  175. type_to_img = {getattr(img, "image_type", ""): img for img in all_images}
  176. return {
  177. "front": type_to_img.get(ImageType.front_fusion.value),
  178. "back": type_to_img.get(ImageType.back_fusion.value),
  179. }
  180. class _DefectCropImageRef:
  181. """裁图用的轻量图片引用(主表 Pydantic 或灰度辅助表行均可)。"""
  182. __slots__ = ("id", "image_type", "image_path")
  183. def __init__(self, image_id: int, image_type: str, image_path: str):
  184. self.id = image_id
  185. self.image_type = image_type
  186. self.image_path = image_path
  187. def _build_defect_crop_pool_by_type(
  188. card_id: int,
  189. all_images: list,
  190. db_conn: PooledMySQLConnection = None,
  191. ) -> dict:
  192. """
  193. 合并主表 card_images 与辅助表 card_gray_images 中的裁图目标。
  194. 同类型主表优先;ring/stripe 导入在灰度表时也能被 defectImgUrls 用到。
  195. """
  196. pool: dict = {}
  197. for img in all_images or []:
  198. image_type = getattr(img, "image_type", "")
  199. if image_type not in _ALL_DEFECT_URL_TARGET_TYPES:
  200. continue
  201. pool[image_type] = img
  202. if db_conn is not None:
  203. cursor = None
  204. try:
  205. cursor = db_conn.cursor(dictionary=True)
  206. cursor.execute(
  207. f"SELECT id, image_type, image_path FROM {settings.DB_GRAY_IMAGE_TABLE_NAME} "
  208. f"WHERE card_id = %s",
  209. (card_id,),
  210. )
  211. for row in cursor.fetchall():
  212. image_type = row.get("image_type") or ""
  213. if image_type not in _ALL_DEFECT_URL_TARGET_TYPES:
  214. continue
  215. if image_type in pool:
  216. continue
  217. pool[image_type] = _DefectCropImageRef(
  218. row["id"],
  219. image_type,
  220. settings.get_full_url(row.get("image_path")),
  221. )
  222. finally:
  223. if cursor:
  224. cursor.close()
  225. return pool
  226. def _defect_url_target_type_list(crop_pool_by_type: dict, side_key: str) -> list:
  227. """同面裁图类型列表;若已有 stripe 则不再使用历史 coaxial。"""
  228. target_types = list(_DEFECT_URL_TARGET_TYPES_BY_SIDE.get(side_key, []))
  229. has_stripe = any(
  230. t.startswith(f"{side_key}_stripe") and t in crop_pool_by_type
  231. for t in target_types
  232. )
  233. if has_stripe:
  234. coaxial = ImageType.front_coaxial.value if side_key == "front" else ImageType.back_coaxial.value
  235. target_types = [t for t in target_types if t != coaxial]
  236. return target_types
  237. def _defect_url_target_images(crop_pool_by_type: dict, side_key: str) -> list:
  238. """按固定类型顺序返回同面需生成 defectImgUrls 的图片(灰度图除外)。"""
  239. targets = []
  240. for image_type in _defect_url_target_type_list(crop_pool_by_type, side_key):
  241. img = crop_pool_by_type.get(image_type)
  242. if img is not None:
  243. targets.append(img)
  244. return targets
  245. def _sanitize_defects_for_recalculate(defects: list):
  246. """
  247. 清理前端展示/编辑辅助字段,减少算分服务解析失败概率。
  248. """
  249. if not isinstance(defects, list):
  250. return
  251. for d in defects:
  252. if not isinstance(d, dict):
  253. continue
  254. if d.get("label") == "slight_scratch":
  255. d["label"] = "scratch"
  256. d.pop("defectImgUrl", None)
  257. d.pop("defectImgUrls", None)
  258. d.pop("gray_id", None)
  259. d.pop("fusion_id", None)
  260. d.pop("edit_type", None)
  261. d.pop("severity_level", None)
  262. d.pop("new_score", None)
  263. def _generate_defect_img_urls_for_json(
  264. card_id: int,
  265. fusion_img_id: int,
  266. json_data: dict,
  267. side_key: str,
  268. crop_pool_by_type: dict,
  269. generate_related_images: bool = True,
  270. ) -> dict:
  271. """
  272. 以融合图 JSON 中的缺陷 min_rect 为准,在同面各目标类型原图上裁图,
  273. 生成 defectImgUrls 并返回 rect_hash -> urls 缓存,供同面其它图复用。
  274. """
  275. start_time = perf_counter()
  276. url_cache_by_rect = {}
  277. if not json_data or "result" not in json_data:
  278. logger.info(
  279. "耗时埋点 _generate_defect_img_urls: card_id=%s fusion_image_id=%s side=%s defects=0 elapsed_ms=%.2f",
  280. card_id, fusion_img_id, side_key, (perf_counter() - start_time) * 1000,
  281. )
  282. return url_cache_by_rect
  283. defect_result = json_data["result"].get("defect_result", {})
  284. defects = defect_result.get("defects", [])
  285. crop_target_images = _defect_url_target_images(crop_pool_by_type, side_key)
  286. target_types = _defect_url_target_type_list(crop_pool_by_type, side_key)
  287. missing_types = [t for t in target_types if t not in crop_pool_by_type]
  288. if missing_types:
  289. logger.info(
  290. "defectImgUrls 裁图目标缺失: card_id=%s side=%s missing=%s",
  291. card_id, side_key, ",".join(missing_types),
  292. )
  293. for idx, defect in enumerate(defects, start=1):
  294. min_rect = defect.get("min_rect")
  295. defect_img_url_list = []
  296. rect_hash = _defect_rect_hash(min_rect)
  297. if min_rect and len(min_rect) == 3 and generate_related_images and crop_target_images:
  298. for s_img in crop_target_images:
  299. s_img_type = getattr(s_img, "image_type", "")
  300. s_img_path = getattr(s_img, "image_path", "")
  301. s_img_id = getattr(s_img, "id", 0)
  302. s_filename = f"xy_{card_id}_{s_img_id}_{idx}_{rect_hash}.jpg"
  303. s_out_rel_path = f"/DefectImage/{s_filename}"
  304. s_out_object_name = f"{settings.MINIO_BASE_PREFIX}{s_out_rel_path}"
  305. s_url = ""
  306. try:
  307. minio_client.stat_object(settings.MINIO_BUCKET, s_out_object_name)
  308. s_url = settings.get_full_url(s_out_rel_path)
  309. except Exception:
  310. if s_img_path:
  311. s_url = crop_defect_image(s_img_path, min_rect, s_filename)
  312. if s_url:
  313. defect_img_url_list.append({
  314. "image_type": s_img_type,
  315. "url": s_url,
  316. })
  317. defect["defectImgUrls"] = defect_img_url_list
  318. if rect_hash:
  319. url_cache_by_rect[rect_hash] = copy.deepcopy(defect_img_url_list)
  320. logger.info(
  321. "耗时埋点 _generate_defect_img_urls: card_id=%s fusion_image_id=%s side=%s "
  322. "defects=%s target_types=%s elapsed_ms=%.2f",
  323. card_id,
  324. fusion_img_id,
  325. side_key,
  326. len(defects),
  327. len(crop_target_images),
  328. (perf_counter() - start_time) * 1000,
  329. )
  330. return url_cache_by_rect
  331. def _apply_defect_img_urls_from_cache(json_data: dict, url_cache_by_rect: dict):
  332. """同面非 canonical 图:按 min_rect 哈希复用已生成的 defectImgUrls,不再访问 MinIO。"""
  333. if not json_data or "result" not in json_data or not url_cache_by_rect:
  334. return
  335. defects = json_data["result"].get("defect_result", {}).get("defects", [])
  336. for defect in defects:
  337. if not isinstance(defect, dict):
  338. continue
  339. rect_hash = _defect_rect_hash(defect.get("min_rect"))
  340. defect["defectImgUrls"] = copy.deepcopy(url_cache_by_rect.get(rect_hash, []))
  341. def _clear_defect_img_urls(json_data: dict):
  342. """灰度图不生成关联缺陷图,清空展示用字段。"""
  343. if not json_data or "result" not in json_data:
  344. return
  345. defects = json_data["result"].get("defect_result", {}).get("defects", [])
  346. for defect in defects:
  347. if isinstance(defect, dict):
  348. defect["defectImgUrls"] = []
  349. def _process_images_to_xy_format(
  350. card_data: dict,
  351. generate_related_images: bool = True,
  352. db_conn: PooledMySQLConnection = None,
  353. ):
  354. """
  355. 内部辅助函数:遍历卡牌数据中的图片,将 JSON 格式转换为前端需要的 XY 格式。
  356. 每面仅以融合图 JSON 生成一次 defectImgUrls(按固定 14 类中的同面非灰类型裁图),
  357. 再拷贝到同面 ring / stripe 等;灰度图不生成 defectImgUrls。
  358. 直接修改传入的 card_data 字典。
  359. """
  360. start_time = perf_counter()
  361. card_id = card_data.get("id")
  362. all_images = card_data.get("images", [])
  363. fusion_by_side = _resolve_fusion_images_by_side(all_images) if all_images else {}
  364. crop_pool_by_type = _build_defect_crop_pool_by_type(
  365. card_id, all_images, db_conn=db_conn,
  366. )
  367. detection_url_cache_by_side = {}
  368. modified_url_cache_by_side = {}
  369. if all_images:
  370. parsed_json_by_img_id = {}
  371. for img in all_images:
  372. d_internal = img.detection_json
  373. if isinstance(d_internal, str):
  374. d_internal = json.loads(d_internal) if d_internal else None
  375. m_internal = img.modified_json
  376. if isinstance(m_internal, str):
  377. m_internal = json.loads(m_internal) if m_internal else None
  378. parsed_json_by_img_id[img.id] = {
  379. "detection": d_internal,
  380. "modified": m_internal,
  381. }
  382. if generate_related_images:
  383. for side_key, fusion_img in fusion_by_side.items():
  384. if not fusion_img:
  385. continue
  386. parsed = parsed_json_by_img_id.get(fusion_img.id, {})
  387. d_internal = parsed.get("detection")
  388. if d_internal:
  389. detection_url_cache_by_side[side_key] = _generate_defect_img_urls_for_json(
  390. card_id,
  391. fusion_img.id,
  392. d_internal,
  393. side_key,
  394. crop_pool_by_type,
  395. generate_related_images=True,
  396. )
  397. m_internal = parsed.get("modified")
  398. if m_internal:
  399. modified_url_cache_by_side[side_key] = _generate_defect_img_urls_for_json(
  400. card_id,
  401. fusion_img.id,
  402. m_internal,
  403. side_key,
  404. crop_pool_by_type,
  405. generate_related_images=True,
  406. )
  407. for img in all_images:
  408. image_type = img.image_type
  409. is_gray = _is_gray_image_type(image_type)
  410. side_key = _side_key_from_image_type(image_type)
  411. parsed = parsed_json_by_img_id.get(img.id, {})
  412. d_internal = parsed.get("detection")
  413. m_internal = parsed.get("modified")
  414. if d_internal:
  415. if is_gray:
  416. _clear_defect_img_urls(d_internal)
  417. elif side_key and detection_url_cache_by_side.get(side_key):
  418. _apply_defect_img_urls_from_cache(
  419. d_internal, detection_url_cache_by_side[side_key],
  420. )
  421. img.detection_json = convert_internal_to_xy_format(d_internal)
  422. else:
  423. img.detection_json = convert_internal_to_xy_format({})
  424. if m_internal:
  425. if is_gray:
  426. _clear_defect_img_urls(m_internal)
  427. elif side_key and modified_url_cache_by_side.get(side_key):
  428. _apply_defect_img_urls_from_cache(
  429. m_internal, modified_url_cache_by_side[side_key],
  430. )
  431. img.modified_json = convert_internal_to_xy_format(m_internal)
  432. else:
  433. m_fallback = copy.deepcopy(d_internal) if d_internal else {}
  434. img.modified_json = convert_internal_to_xy_format(m_fallback)
  435. logger.info(
  436. "耗时埋点 _process_images_to_xy_format: card_id=%s image_count=%s elapsed_ms=%.2f",
  437. card_id, len(all_images), (perf_counter() - start_time) * 1000
  438. )
  439. return card_data
  440. def pregenerate_defect_images_for_card(db_conn: PooledMySQLConnection, card_id: int) -> int:
  441. """
  442. 导入阶段预生成缺陷裁图:以融合图 JSON 为准,按面在同面各类型原图上裁图并写入 MinIO。
  443. 查询接口 get_card_details 命中已存在的裁图后即可直接拼 URL,无需再实时裁图。
  444. 返回本次涉及裁图的缺陷数量(仅用于日志/统计)。
  445. """
  446. start_time = perf_counter()
  447. card_data = crud_card.get_card_with_details(db_conn, card_id)
  448. if not card_data:
  449. logger.warning("预生成缺陷裁图跳过:card_id=%s 未找到卡牌", card_id)
  450. return 0
  451. all_images = card_data.get("images", [])
  452. if not all_images:
  453. return 0
  454. fusion_by_side = _resolve_fusion_images_by_side(all_images)
  455. crop_pool_by_type = _build_defect_crop_pool_by_type(card_id, all_images, db_conn=db_conn)
  456. total_defects = 0
  457. for side_key, fusion_img in fusion_by_side.items():
  458. if not fusion_img:
  459. continue
  460. for json_field in ("detection_json", "modified_json"):
  461. raw_json = getattr(fusion_img, json_field, None)
  462. if isinstance(raw_json, str):
  463. raw_json = json.loads(raw_json) if raw_json else None
  464. if not raw_json:
  465. continue
  466. # 复制一份,避免污染 Pydantic 对象;只关心裁图副作用(写入 MinIO)
  467. cache = _generate_defect_img_urls_for_json(
  468. card_id,
  469. fusion_img.id,
  470. copy.deepcopy(raw_json),
  471. side_key,
  472. crop_pool_by_type,
  473. generate_related_images=True,
  474. )
  475. total_defects += len(cache)
  476. logger.info(
  477. "预生成缺陷裁图完成: card_id=%s defects=%s elapsed_ms=%.2f",
  478. card_id, total_defects, (perf_counter() - start_time) * 1000,
  479. )
  480. return total_defects
  481. @router.get("/query", response_model=CardDetailResponse, summary="获取卡牌详细信息(格式化xy), 支持前后翻页 [用户调用]")
  482. def get_card_details(
  483. card_id: int = Query(..., description="基准卡牌ID"),
  484. mode: QueryMode = Query(QueryMode.current, description="查询模式: current(当前), next(下一个), prev(上一个)"),
  485. db_conn: PooledMySQLConnection = db_dependency
  486. ):
  487. """
  488. 获取卡牌元数据以及所有与之关联的图片信息,并将坐标转换为 xy 格式。
  489. 同时返回上一张和下一张卡牌的ID。
  490. - **current**: 查询 card_id 对应的卡牌。
  491. - **next**: 查询 ID 比 card_id 大的第一张卡牌。
  492. - **prev**: 查询 ID 比 card_id 小的第一张卡牌。
  493. """
  494. target_id = card_id
  495. cursor = None
  496. start_time = perf_counter()
  497. try:
  498. cursor = db_conn.cursor(dictionary=True)
  499. # 1. 如果是查询上一个或下一个,先计算目标ID
  500. if mode != QueryMode.current:
  501. if mode == QueryMode.next:
  502. query_target = (f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} "
  503. f"WHERE id > %s ORDER BY id ASC LIMIT 1")
  504. else: # mode == QueryMode.prev
  505. query_target = (f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} "
  506. f"WHERE id < %s ORDER BY id DESC LIMIT 1")
  507. cursor.execute(query_target, (card_id,))
  508. row = cursor.fetchone()
  509. if not row:
  510. msg = "没有下一张" if mode == QueryMode.next else "没有上一张"
  511. # 边界场景返回 404,避免与 response_model=CardDetailResponse 冲突
  512. raise HTTPException(status_code=404, detail=msg)
  513. target_id = row['id']
  514. # 2. 获取目标卡牌的详细数据 (Dict 格式)
  515. card_data = crud_card.get_card_with_details(db_conn, target_id)
  516. if not card_data:
  517. raise HTTPException(status_code=404, detail=f"ID为 {target_id} 的卡牌未找到。")
  518. # 3. 补充当前目标卡牌的 id_prev 和 id_next
  519. # 注意:这里需要重新获取 cursor,或者使用 cursor (非 dict 模式可能更方便取值,但 dict 模式也行)
  520. # 这里为了简单直接用 raw SQL
  521. # 查询上一个ID
  522. sql_prev = f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} WHERE id < %s ORDER BY id DESC LIMIT 1"
  523. cursor.execute(sql_prev, (target_id,))
  524. row_prev = cursor.fetchone()
  525. card_data['id_prev'] = row_prev['id'] if row_prev else None
  526. # 查询下一个ID
  527. sql_next = f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} WHERE id > %s ORDER BY id ASC LIMIT 1"
  528. cursor.execute(sql_next, (target_id,))
  529. row_next = cursor.fetchone()
  530. card_data['id_next'] = row_next['id'] if row_next else None
  531. # 4. 遍历图片,转换格式 (使用抽取出的辅助函数)
  532. _process_images_to_xy_format(
  533. card_data,
  534. generate_related_images=True,
  535. db_conn=db_conn,
  536. )
  537. # 5. 将 images 从 Pydantic 对象转为 dict,避免 model_validate 重复验证导致类型异常
  538. if "images" in card_data:
  539. card_data["images"] = [
  540. img.model_dump() if hasattr(img, 'model_dump') else img
  541. for img in card_data["images"]
  542. ]
  543. # 6. 验证并返回
  544. return CardDetailResponse.model_validate(card_data)
  545. except HTTPException:
  546. raise
  547. except Exception as e:
  548. logger.error(f"查询卡牌详情失败 (Mode: {mode}, BaseID: {card_id}): {e}")
  549. raise HTTPException(status_code=500, detail="数据库查询失败")
  550. finally:
  551. logger.info(
  552. "耗时埋点 get_card_details: base_card_id=%s target_card_id=%s mode=%s elapsed_ms=%.2f",
  553. card_id, target_id, mode, (perf_counter() - start_time) * 1000
  554. )
  555. if cursor:
  556. cursor.close()
  557. @router.put("/update/json/{id}", status_code=200, summary="接收xy格式, 还原后重计算分数并保存 [用户调用]")
  558. async def update_image_modified_json(
  559. id: int,
  560. new_json_data: dict = Body(..., description="前端传来的包含xy对象格式的JSON"),
  561. current_user: dict = Depends(get_current_user),
  562. db_conn: PooledMySQLConnection = db_dependency
  563. ):
  564. """
  565. 接收前端传来的特殊格式 JSON (points 为对象列表)。
  566. 1. 将格式还原为后端标准格式 (points 为 [[x,y]])。
  567. 2. 根据 id 获取 image_type。
  568. 3. 调用外部接口重新计算分数。
  569. 4. 更新 modified_json。
  570. """
  571. card_id_to_update = None
  572. cursor = None
  573. # *** 1. 格式还原 ***
  574. # 将前端的 xy dict 格式转回 [[x,y]]
  575. internal_json_payload = convert_xy_to_internal_format(new_json_data)
  576. try:
  577. cursor = db_conn.cursor(dictionary=True)
  578. # 2. 获取图片信息
  579. cursor.execute(
  580. f"SELECT image_type, card_id, detection_json, modified_json "
  581. f"FROM {settings.DB_IMAGE_TABLE_NAME} WHERE id = %s",
  582. (id,)
  583. )
  584. row = cursor.fetchone()
  585. if not row:
  586. raise HTTPException(status_code=404, detail=f"ID为 {id} 的图片未找到。")
  587. card_id_to_update = row["card_id"]
  588. check_card_permission(db_conn, current_user, card_id_to_update)
  589. image_type = row["image_type"]
  590. # score_recalculate 接口只接受 coaxial / ring 类型的 score_type,
  591. # 融合图/灰度图/调光图都按正反面归到对应 ring。
  592. score_type = _resolve_recalc_score_type(image_type)
  593. if not score_type:
  594. raise HTTPException(status_code=400, detail=f"未知的 image_type: {image_type}")
  595. # 3. 准备重算 payload:以库内原始 JSON 为底稿,仅覆盖编辑后的 defects
  596. # 对 fusion 图,score_type 会映射到 ring;此时底稿也应优先使用 ring 图 JSON,
  597. # 否则 fusion 图常见的空框结构会导致算分服务在 ring 逻辑下越界。
  598. source_json_str = row["modified_json"] if row["modified_json"] else row["detection_json"]
  599. if image_type in (ImageType.front_fusion.value, ImageType.back_fusion.value):
  600. target_ring_type = (
  601. ImageType.front_ring.value
  602. if image_type == ImageType.front_fusion.value
  603. else ImageType.back_ring.value
  604. )
  605. cursor.execute(
  606. f"SELECT detection_json, modified_json FROM {settings.DB_IMAGE_TABLE_NAME} "
  607. f"WHERE card_id = %s AND image_type = %s LIMIT 1",
  608. (card_id_to_update, target_ring_type)
  609. )
  610. ring_row = cursor.fetchone()
  611. if ring_row:
  612. source_json_str = ring_row["modified_json"] if ring_row["modified_json"] else ring_row["detection_json"]
  613. else:
  614. logger.warning(
  615. "fusion图重算未找到对应ring底稿,回退到当前图: image_id=%s card_id=%s image_type=%s target_ring_type=%s",
  616. id, card_id_to_update, image_type, target_ring_type
  617. )
  618. if isinstance(source_json_str, str):
  619. source_json_data = json.loads(source_json_str)
  620. else:
  621. source_json_data = source_json_str if isinstance(source_json_str, dict) else {}
  622. payload_for_recalculate = _prepare_recalculate_payload(internal_json_payload, source_json_data)
  623. _defects = payload_for_recalculate.get("result", {}).get("defect_result", {}).get("defects", [])
  624. _sanitize_defects_for_recalculate(_defects)
  625. logger.info(f"开始计算分数 (ID: {id}, Type: {score_type})")
  626. # 4. 调用远程计算接口
  627. try:
  628. response = await run_in_threadpool(
  629. lambda: requests.post(
  630. settings.SCORE_RECALCULATE_ENDPOINT,
  631. params={"score_type": score_type},
  632. json=payload_for_recalculate,
  633. timeout=20
  634. )
  635. )
  636. except Exception as e:
  637. logger.error(
  638. "调用分数计算服务失败(update/json): image_id=%s card_id=%s score_type=%s endpoint=%s error=%s",
  639. id, card_id_to_update, score_type, settings.SCORE_RECALCULATE_ENDPOINT, e,
  640. exc_info=True
  641. )
  642. raise HTTPException(status_code=500, detail=f"调用分数计算服务失败: {e}")
  643. if response.status_code != 200:
  644. logger.error(
  645. "分数计算接口返回错误(update/json): image_id=%s card_id=%s score_type=%s endpoint=%s status=%s body=%s",
  646. id, card_id_to_update, score_type, settings.SCORE_RECALCULATE_ENDPOINT, response.status_code, response.text
  647. )
  648. raise HTTPException(status_code=response.status_code,
  649. detail=f"分数计算接口返回错误: {response.text}")
  650. logger.info("分数计算完成")
  651. # 获取计算服务返回的结果(这个结果通常已经是标准的 internal 格式,带有分数和面积)
  652. final_json_data = response.json()
  653. # 5. 保存结果到数据库
  654. recalculated_json_str = json.dumps(final_json_data, ensure_ascii=False)
  655. update_query = (f"UPDATE {settings.DB_IMAGE_TABLE_NAME} "
  656. f"SET modified_json = %s, is_edited = TRUE "
  657. f"WHERE id = %s")
  658. cursor.execute(update_query, (recalculated_json_str, id))
  659. db_conn.commit()
  660. logger.info(f"图片ID {id} 的 modified_json 已更新并重新计算。")
  661. # 更新对应的 cards 的分数状态
  662. try:
  663. crud_card.update_card_scores_and_status(db_conn, card_id_to_update)
  664. logger.info(f"卡牌 {card_id_to_update} 的分数和状态已更新。")
  665. except Exception as score_update_e:
  666. logger.error(f"更新卡牌 {card_id_to_update} 分数失败: {score_update_e}")
  667. # 更新卡牌审核状态
  668. try:
  669. with db_conn.cursor() as cursor:
  670. review_state = 2
  671. # 更新指定 card_id 的 review_state 字段
  672. # 注意:MySQL 在“值未变化”时 rowcount 可能为 0,这不代表记录不存在。
  673. query_update = f"UPDATE {settings.DB_CARD_TABLE_NAME} SET review_state = %s WHERE id = %s"
  674. cursor.execute(query_update, (review_state, card_id_to_update))
  675. if cursor.rowcount == 0:
  676. cursor.execute(f"SELECT 1 FROM {settings.DB_CARD_TABLE_NAME} WHERE id = %s LIMIT 1",
  677. (card_id_to_update,))
  678. if not cursor.fetchone():
  679. raise HTTPException(status_code=404, detail=f"ID为 {card_id_to_update} 的卡牌未找到。")
  680. db_conn.commit()
  681. logger.info(f"卡牌 ID {card_id_to_update} 的审核状态已成功修改为 {review_state}。")
  682. except Exception as e:
  683. db_conn.rollback()
  684. logger.error(f"修改卡牌 {id} 审核状态失败: {e}")
  685. if isinstance(e, HTTPException):
  686. raise e
  687. raise HTTPException(status_code=500, detail="修改审核状态失败,数据库操作错误。")
  688. return {
  689. "detail": f"成功更新图片ID {id} 的JSON数据",
  690. "image_type": image_type,
  691. "score_type": score_type
  692. }
  693. except HTTPException:
  694. db_conn.rollback()
  695. raise
  696. except Exception as e:
  697. db_conn.rollback()
  698. logger.error(f"更新JSON失败 ({id}): {e}")
  699. raise HTTPException(status_code=500, detail=f"更新JSON数据失败: {e}")
  700. finally:
  701. if cursor:
  702. cursor.close()
  703. # 处理灰度如
  704. @router.put("/update/json_gray/{id}", status_code=200, summary="[灰度] 接收xy格式, 合并至Ring图重计算并保存 [用户调用]")
  705. async def update_gray_image_json(
  706. id: int,
  707. new_json_data: dict = Body(..., description="前端传来的灰度图编辑后的JSON(xy格式)"),
  708. current_user: dict = Depends(get_current_user),
  709. db_conn: PooledMySQLConnection = db_dependency
  710. ):
  711. """
  712. 针对灰度图 (front_gray/back_gray) 的保存逻辑。
  713. """
  714. cursor = None
  715. # 1. 格式还原
  716. internal_gray_json = convert_xy_to_internal_format(new_json_data)
  717. gray_defects = internal_gray_json.get("result", {}).get("defect_result", {}).get("defects", [])
  718. # 丢弃前端展示用的辅助字段,防止传给算分服务导致报错
  719. for d in gray_defects:
  720. if d.get("label") == "slight_scratch":
  721. d["label"] = "scratch"
  722. d.pop("defectImgUrl", None)
  723. d.pop("defectImgUrls", None)
  724. try:
  725. cursor = db_conn.cursor(dictionary=True)
  726. # 2. 获取辅助图(灰度图/融合图)信息
  727. # 以前只查 card_gray_images,现在融合图是在 card_images 表里
  728. # 先查 card_gray_images
  729. cursor.execute(f"SELECT card_id, image_type FROM {settings.DB_GRAY_IMAGE_TABLE_NAME} WHERE id = %s", (id,))
  730. gray_row = cursor.fetchone()
  731. if not gray_row:
  732. # 如果灰度表没找到,去主表找找看是不是融合图
  733. cursor.execute(f"SELECT card_id, image_type FROM {settings.DB_IMAGE_TABLE_NAME} WHERE id = %s AND image_type IN ('front_fusion', 'back_fusion')", (id,))
  734. gray_row = cursor.fetchone()
  735. if not gray_row:
  736. raise HTTPException(status_code=404, detail=f"ID为 {id} 的辅助图未找到。")
  737. card_id = gray_row['card_id']
  738. check_card_permission(db_conn, current_user, card_id)
  739. gray_image_type = gray_row['image_type']
  740. # 3. 确定目标 Ring 图类型
  741. target_ring_type = None
  742. if gray_image_type in (ImageType.front_gray.value, ImageType.front_fusion.value):
  743. target_ring_type = ImageType.front_ring.value
  744. elif gray_image_type in (ImageType.back_gray.value, ImageType.back_fusion.value):
  745. target_ring_type = ImageType.back_ring.value
  746. else:
  747. raise HTTPException(status_code=400, detail=f"不支持的辅助图类型: {gray_image_type}")
  748. # 4. 获取目标 Ring 图数据 (Card Images 表)
  749. cursor.execute(
  750. f"SELECT id, detection_json, modified_json FROM {settings.DB_IMAGE_TABLE_NAME} "
  751. f"WHERE card_id = %s AND image_type = %s",
  752. (card_id, target_ring_type)
  753. )
  754. ring_row = cursor.fetchone()
  755. if not ring_row:
  756. raise HTTPException(status_code=404, detail=f"未找到对应的 Ring 图 ({target_ring_type}),无法应用修改。")
  757. ring_image_id = ring_row['id']
  758. # 优先使用 modified_json,如果没有则使用 detection_json
  759. source_json_str = ring_row['modified_json'] if ring_row['modified_json'] else ring_row['detection_json']
  760. if isinstance(source_json_str, str):
  761. ring_json_data = json.loads(source_json_str)
  762. else:
  763. ring_json_data = source_json_str
  764. # 5. 合并逻辑 (Merge Logic)
  765. # 确保路径存在
  766. if "result" not in ring_json_data: ring_json_data["result"] = {}
  767. if "defect_result" not in ring_json_data["result"]: ring_json_data["result"]["defect_result"] = {}
  768. if "defects" not in ring_json_data["result"]["defect_result"]: ring_json_data["result"]["defect_result"][
  769. "defects"] = []
  770. ring_defects = ring_json_data["result"]["defect_result"]["defects"]
  771. # 遍历灰度图传来的新缺陷列表
  772. for new_defect in gray_defects:
  773. is_fusion = gray_image_type in (ImageType.front_fusion.value, ImageType.back_fusion.value)
  774. key_to_check = "fusion_id" if is_fusion else "gray_id"
  775. identifier = new_defect.get(key_to_check)
  776. # 只有带有对应标识的才进行特殊合并处理
  777. # 如果没有,视作普通新缺陷直接添加
  778. if not identifier:
  779. ring_defects.append(new_defect)
  780. continue
  781. # 在 Ring 图现有的缺陷中寻找匹配的标识
  782. match_index = -1
  783. for i, old_defect in enumerate(ring_defects):
  784. if old_defect.get(key_to_check) == identifier:
  785. match_index = i
  786. break
  787. if match_index != -1:
  788. # 存在:替换 (Replace)
  789. ring_defects[match_index] = new_defect
  790. else:
  791. # 不存在:添加 (Append)
  792. ring_defects.append(new_defect)
  793. # 6. 调用计算服务 (对 Ring 图数据进行重算)
  794. # score_recalculate 接口接受 ring 类型,直接用目标 ring 类型即可
  795. score_type = _resolve_recalc_score_type(target_ring_type)
  796. logger.info(f"开始重计算 Ring 图分数 (GrayID: {id} -> RingID: {ring_image_id}, Type: {score_type})")
  797. try:
  798. response = await run_in_threadpool(
  799. lambda: requests.post(
  800. settings.SCORE_RECALCULATE_ENDPOINT,
  801. params={"score_type": score_type},
  802. json=ring_json_data, # 发送合并后的 Ring 数据
  803. timeout=20
  804. )
  805. )
  806. except Exception as e:
  807. logger.error(
  808. "调用分数计算服务失败(update/json_gray): gray_id=%s ring_id=%s card_id=%s score_type=%s endpoint=%s error=%s",
  809. id, ring_image_id, card_id, score_type, settings.SCORE_RECALCULATE_ENDPOINT, e,
  810. exc_info=True
  811. )
  812. raise HTTPException(status_code=500, detail=f"调用分数计算服务失败: {e}")
  813. if response.status_code != 200:
  814. logger.error(
  815. "分数计算接口返回错误(update/json_gray): gray_id=%s ring_id=%s card_id=%s score_type=%s endpoint=%s status=%s body=%s",
  816. id, ring_image_id, card_id, score_type, settings.SCORE_RECALCULATE_ENDPOINT, response.status_code, response.text
  817. )
  818. raise HTTPException(status_code=response.status_code,
  819. detail=f"分数计算接口返回错误: {response.text}")
  820. final_ring_json = response.json()
  821. # 7. 保存结果到数据库 (保存到 Ring 图记录)
  822. final_json_str = json.dumps(final_ring_json, ensure_ascii=False)
  823. update_query = (
  824. f"UPDATE {settings.DB_IMAGE_TABLE_NAME} "
  825. f"SET modified_json = %s, is_edited = TRUE "
  826. f"WHERE id = %s"
  827. )
  828. cursor.execute(update_query, (final_json_str, ring_image_id))
  829. db_conn.commit()
  830. logger.info(f"Ring 图 {ring_image_id} 已根据灰度图 {id} 的修改进行了更新。")
  831. # 8. 更新卡牌总分状态
  832. try:
  833. crud_card.update_card_scores_and_status(db_conn, card_id)
  834. except Exception as e:
  835. logger.error(f"更新卡牌 {card_id} 分数状态失败: {e}")
  836. # 更新卡牌审核状态
  837. try:
  838. with db_conn.cursor() as cursor:
  839. review_state = 2
  840. # 更新指定 card_id 的 review_state 字段
  841. # 注意:MySQL 在 “值未变化” 的情况下 rowcount 可能为 0,但这不代表记录不存在。
  842. query_update = f"UPDATE {settings.DB_CARD_TABLE_NAME} SET review_state = %s WHERE id = %s"
  843. cursor.execute(query_update, (review_state, card_id))
  844. if cursor.rowcount == 0:
  845. cursor.execute(f"SELECT 1 FROM {settings.DB_CARD_TABLE_NAME} WHERE id = %s LIMIT 1", (card_id,))
  846. if not cursor.fetchone():
  847. raise HTTPException(status_code=404, detail=f"ID为 {card_id} 的卡牌未找到。")
  848. db_conn.commit()
  849. logger.info(f"卡牌 ID {card_id} 的审核状态已成功修改为 {review_state}。")
  850. except Exception as e:
  851. db_conn.rollback()
  852. logger.error(f"修改卡牌 {id} 审核状态失败: {e}")
  853. if isinstance(e, HTTPException):
  854. raise e
  855. raise HTTPException(status_code=500, detail="修改审核状态失败,数据库操作错误。")
  856. return {
  857. "detail": f"成功应用灰度图修改到 {target_ring_type}",
  858. "target_ring_id": ring_image_id,
  859. "gray_id": id
  860. }
  861. except HTTPException:
  862. db_conn.rollback()
  863. raise
  864. except Exception as e:
  865. db_conn.rollback()
  866. logger.error(f"灰度图更新失败 ({id}): {e}")
  867. raise HTTPException(status_code=500, detail=f"系统内部错误: {e}")
  868. finally:
  869. if cursor:
  870. cursor.close()