hazard.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. """
  2. 隐患识别路由
  3. """
  4. from fastapi import APIRouter, Depends, Request, File, UploadFile
  5. from sqlalchemy.orm import Session
  6. from pydantic import BaseModel
  7. from typing import Optional
  8. from database import get_db
  9. from models.scene import RecognitionRecord
  10. from services.yolo_service import yolo_service
  11. from services.oss_service import oss_service
  12. from utils.logger import logger
  13. from utils.crypto import decrypt_url
  14. from PIL import Image, ImageDraw, ImageFont
  15. import io
  16. import httpx
  17. import time
  18. import math
  19. import json
  20. router = APIRouter()
  21. class HazardRequest(BaseModel):
  22. """隐患识别请求"""
  23. image_url: str
  24. scene_type: str = ""
  25. user_name: str = ""
  26. user_account: str = ""
  27. class SaveStepRequest(BaseModel):
  28. """保存步骤请求"""
  29. record_id: int
  30. current_step: int
  31. @router.post("/hazard")
  32. async def hazard(
  33. request: Request,
  34. data: HazardRequest,
  35. db: Session = Depends(get_db)
  36. ):
  37. """
  38. 隐患识别接口
  39. 流程:
  40. 1. 从 OSS 代理 URL 解密获取真实 URL
  41. 2. 下载图片到内存
  42. 3. 调用 YOLO 服务识别
  43. 4. 绘制边界框 + 水印(用户名/账号/日期)
  44. 5. 上传结果图片到 OSS
  45. 6. 插入 RecognitionRecord
  46. 7. 返回结果
  47. """
  48. user = request.state.user
  49. if not user:
  50. return {"statusCode": 401, "msg": "未授权"}
  51. try:
  52. # 1. 解密 OSS URL
  53. try:
  54. real_image_url = decrypt_url(data.image_url)
  55. except:
  56. # 如果解密失败,可能是直接的 URL
  57. real_image_url = data.image_url
  58. # 2. 下载图片到内存
  59. async with httpx.AsyncClient(timeout=30.0) as client:
  60. img_response = await client.get(real_image_url)
  61. img_response.raise_for_status()
  62. image_bytes = img_response.content
  63. # 3. 调用 YOLO 服务识别
  64. # 先上传图片到临时位置,或者传递 URL
  65. yolo_result = await yolo_service.detect_hazards(real_image_url, data.scene_type)
  66. hazards = yolo_result.get("hazards", [])
  67. hazard_count = len(hazards)
  68. # 4. 绘制边界框和水印
  69. result_image_bytes = await _draw_boxes_and_watermark(
  70. image_bytes,
  71. hazards,
  72. user_name=data.user_name or user.account,
  73. user_account=user.account,
  74. )
  75. # 5. 上传结果图片到 OSS
  76. result_filename = f"hazard_detection/{user.userCode}/{int(time.time())}.jpg"
  77. result_url = await oss_service.upload_bytes(result_image_bytes, result_filename)
  78. # 6. 插入 RecognitionRecord
  79. record = RecognitionRecord(
  80. user_id=user.userCode,
  81. scene_type=data.scene_type,
  82. original_image_url=data.image_url,
  83. recognition_image_url=result_url,
  84. hazard_count=hazard_count,
  85. hazard_details=json.dumps(hazards, ensure_ascii=False),
  86. current_step=1,
  87. created_at=int(time.time()),
  88. updated_at=int(time.time()),
  89. is_deleted=0
  90. )
  91. db.add(record)
  92. db.commit()
  93. db.refresh(record)
  94. # 7. 返回结果
  95. return {
  96. "statusCode": 200,
  97. "msg": "识别成功",
  98. "data": {
  99. "record_id": record.id,
  100. "hazard_count": hazard_count,
  101. "hazards": hazards,
  102. "result_image_url": result_url,
  103. "original_image_url": data.image_url
  104. }
  105. }
  106. except httpx.HTTPError as e:
  107. logger.error(f"[hazard] 图片下载失败: {e}")
  108. return {"statusCode": 500, "msg": f"图片下载失败: {str(e)}"}
  109. except Exception as e:
  110. logger.error(f"[hazard] 处理异常: {e}")
  111. return {"statusCode": 500, "msg": f"处理失败: {str(e)}"}
  112. @router.post("/save_step")
  113. async def save_step(
  114. request: Request,
  115. data: SaveStepRequest,
  116. db: Session = Depends(get_db)
  117. ):
  118. """
  119. 保存识别步骤
  120. 更新 RecognitionRecord.current_step
  121. """
  122. user = request.state.user
  123. if not user:
  124. return {"statusCode": 401, "msg": "未授权"}
  125. try:
  126. # 更新步骤
  127. affected = db.query(RecognitionRecord).filter(
  128. RecognitionRecord.id == data.record_id,
  129. RecognitionRecord.user_id == user.userCode
  130. ).update({
  131. "current_step": data.current_step,
  132. "updated_at": int(time.time())
  133. })
  134. if affected == 0:
  135. return {"statusCode": 404, "msg": "记录不存在"}
  136. db.commit()
  137. return {
  138. "statusCode": 200,
  139. "msg": "保存成功",
  140. "data": {
  141. "record_id": data.record_id,
  142. "current_step": data.current_step
  143. }
  144. }
  145. except Exception as e:
  146. logger.error(f"[save_step] 异常: {e}")
  147. db.rollback()
  148. return {"statusCode": 500, "msg": f"保存失败: {str(e)}"}
  149. async def _draw_boxes_and_watermark(
  150. image_bytes: bytes,
  151. hazards: list,
  152. user_name: str,
  153. user_account: str
  154. ) -> bytes:
  155. """
  156. 在图片上绘制边界框和水印(对齐Go版本)
  157. 功能:
  158. 1. 绘制检测边界框
  159. 2. 添加45度角水印(用户名、账号、日期)
  160. Args:
  161. image_bytes: 原始图片字节
  162. hazards: YOLO 检测结果列表,每项包含 bbox, label, confidence
  163. user_name: 用户名
  164. user_account: 用户账号
  165. Returns:
  166. 处理后的图片字节
  167. """
  168. try:
  169. # 打开图片
  170. image = Image.open(io.BytesIO(image_bytes)).convert("RGBA")
  171. width, height = image.size
  172. # 创建透明图层用于绘制
  173. overlay = Image.new("RGBA", (width, height), (255, 255, 255, 0))
  174. draw = ImageDraw.Draw(overlay)
  175. # 尝试加载字体
  176. try:
  177. font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20)
  178. font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14)
  179. except:
  180. try:
  181. # Windows字体路径
  182. font = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 20)
  183. font_small = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 14)
  184. except:
  185. font = ImageFont.load_default()
  186. font_small = ImageFont.load_default()
  187. # 1. 绘制边界框
  188. for hazard in hazards:
  189. bbox = hazard.get("bbox", [])
  190. label = hazard.get("label", "")
  191. confidence = hazard.get("confidence", 0)
  192. if len(bbox) == 4:
  193. x1, y1, x2, y2 = bbox
  194. # 绘制矩形框(红色)
  195. draw.rectangle([x1, y1, x2, y2], outline=(255, 0, 0, 255), width=3)
  196. # 绘制标签
  197. text = f"{label} {confidence:.2f}"
  198. draw.text((x1, max(0, y1 - 25)), text, fill=(255, 0, 0, 255), font=font)
  199. # 2. 添加45度角水印(对齐Go版本)
  200. current_date = time.strftime("%Y/%m/%d")
  201. watermarks = [user_name, user_account, current_date]
  202. # 水印参数
  203. text_height_estimate = 50
  204. text_width_estimate = 150
  205. angle = 45
  206. # 创建水印文本图层
  207. watermark_layer = Image.new("RGBA", (width * 2, height * 2), (255, 255, 255, 0))
  208. watermark_draw = ImageDraw.Draw(watermark_layer)
  209. # 45度角平铺水印
  210. for y in range(-height, height * 2, text_height_estimate):
  211. for x in range(-width, width * 2, text_width_estimate):
  212. # 计算当前行使用哪个水印文本
  213. row_index = int(y / text_height_estimate) % len(watermarks)
  214. text = watermarks[row_index]
  215. # 使用更深的灰色(对齐Go版本)
  216. watermark_draw.text(
  217. (x, y),
  218. text,
  219. fill=(128, 128, 128, 60), # 半透明灰色
  220. font=font_small
  221. )
  222. # 旋转水印层
  223. watermark_layer = watermark_layer.rotate(angle, expand=False, fillcolor=(255, 255, 255, 0))
  224. # 裁剪到原始尺寸
  225. crop_x = (watermark_layer.width - width) // 2
  226. crop_y = (watermark_layer.height - height) // 2
  227. watermark_layer = watermark_layer.crop((crop_x, crop_y, crop_x + width, crop_y + height))
  228. # 合并图层
  229. image = Image.alpha_composite(image, watermark_layer)
  230. image = Image.alpha_composite(image, overlay)
  231. # 转换为RGB并保存
  232. final_image = image.convert("RGB")
  233. output = io.BytesIO()
  234. final_image.save(output, format="JPEG", quality=95)
  235. return output.getvalue()
  236. except Exception as e:
  237. logger.error(f"[_draw_boxes_and_watermark] 图片处理失败: {e}")
  238. # 如果处理失败,返回原图
  239. return image_bytes