Jelajahi Sumber

加入minio和识别

AnlaAnla 3 minggu lalu
induk
melakukan
f27d14541f

+ 108 - 0
Test/minio_and_recognizi/cloud_api_stub.py

@@ -0,0 +1,108 @@
+import asyncio
+import aiohttp
+from minio_client import minio_client, get_full_url
+from services import MINIO_BUCKET, MINIO_BASE_PREFIX, RECOGNIZE_API_URL, DATA_PREFIX
+import time
+import datetime
+import os
+
+
+async def upload_image_to_cloud(local_file_path: str) -> str:
+    """
+    实际调用图片服务器上传接口
+    """
+    print(f"正在上传: {local_file_path} 到 minio")
+
+    if not os.path.exists(local_file_path):
+        print("错误: 本地文件不存在")
+        return None
+
+    try:
+        # 格式化输出:2023-10-27 10:00:00
+        formatted_time = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+        object_name = f"raspi_{formatted_time}.jpg"
+
+        cloud_path = os.path.join(MINIO_BASE_PREFIX, object_name).replace("\\", "/")
+
+        minio_client.fput_object(MINIO_BUCKET, cloud_path, local_file_path, content_type="image/jpeg")
+        print(f"成功上传 {object_name} 到 {MINIO_BUCKET}")
+
+        img_url = get_full_url(object_name)
+        return img_url
+    except Exception as e:
+        print(f"上传异常: {e}")
+        return None
+
+
+async def recognize_card_info(local_file_path: str) -> dict:
+    """
+    调用卡片识别接口
+    """
+    print(f"[Recognize] 正在识别卡片: {local_file_path} ...")
+
+    EMPTY_CARD_INFO = {
+        "year": "",
+        "series": "",
+        "cardSet": "",
+        "player": "",
+        "confidentialId": "",
+        "limitId": ""
+    }
+
+    if not os.path.exists(local_file_path):
+        print("[Recognize] 文件不存在,返回空信息")
+        return EMPTY_CARD_INFO
+
+    try:
+        async with aiohttp.ClientSession() as session:
+            # 构造 Multipart/form-data
+            data = aiohttp.FormData()
+            # 根据 curl -F 'imgFile=@...',字段名必须是 'imgFile'
+            data.add_field('imgFile',
+                           open(local_file_path, 'rb'),
+                           filename=os.path.basename(local_file_path),
+                           content_type='image/jpeg')
+
+            # 发送请求
+            async with session.post(RECOGNIZE_API_URL, data=data, timeout=15) as response:
+                if response.status != 200:
+                    print(f"[Recognize] 接口报错: Status {response.status}")
+                    text = await response.text()
+                    print(f"[Recognize] 错误内容: {text}")
+                    return EMPTY_CARD_INFO
+
+                # 解析 JSON
+                result_list = await response.json()
+
+                # 校验返回数据是否有效
+                if not result_list or not isinstance(result_list, list):
+                    print("[Recognize] 返回格式不是列表或为空")
+                    return EMPTY_CARD_INFO
+
+                # 取第一个结果
+                first_item = result_list[0]
+                detail = first_item.get("detail", {})
+
+                # 映射字段
+                # mapping: no -> confidentialId
+                path = detail.get("path", "")
+                if path!="" or path is not None:
+                    path = f"{DATA_PREFIX}{path}"
+                info = {
+                    "year": detail.get("year", ""),
+                    "series": detail.get("series", ""),
+                    "cardSet": detail.get("cardset", ""),
+                    "player": detail.get("player", ""),
+                    "confidentialId": detail.get("no", ""),
+                    "limitId": "",  # 源数据没有,留空
+                    "path": path
+                }
+
+                print(f"[Recognize] 识别成功: {first_item.get('tag', 'No Tag')}")
+                return info
+
+    except Exception as e:
+        print(f"[Recognize] 请求发生异常: {e}")
+        import traceback
+        traceback.print_exc()
+        return EMPTY_CARD_INFO

+ 21 - 0
Test/minio_and_recognizi/minio_client.py

@@ -0,0 +1,21 @@
+from minio import Minio
+from services import MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY, MINIO_SECURE, DATA_HOST_URL
+
+# 初始化全局 MinIO 客户端
+minio_client = Minio(
+    MINIO_ENDPOINT,
+    access_key=MINIO_ACCESS_KEY,
+    secret_key=MINIO_SECRET_KEY,
+    secure=MINIO_SECURE
+)
+
+
+def get_full_url(path: str) -> str:
+    """将相对路径转换为可以直接打开的 MinIO 绝对 URL"""
+    if not path:
+        return path
+    if str(path).startswith("http"):
+        return path
+    # 移除开头的斜杠防止双斜杠 (如: /Data/xxx -> Data/xxx)
+    clean_path = str(path).lstrip("/\\")
+    return f"{DATA_HOST_URL}/{clean_path}"

+ 18 - 0
Test/minio_and_recognizi/services.py

@@ -0,0 +1,18 @@
+
+# --- 配置地址 ---
+# 图片存储服务器地址
+MINIO_ENDPOINT = "192.168.77.249:9000"
+MINIO_ACCESS_KEY = "pZEwCGnpNN05KPnmC2Yh"
+MINIO_SECRET_KEY = "KfJRuWiv9pVxhIMcFqbkv8hZT9SnNTZ6LPx592D4"  # 替换为你的 Secret Key
+MINIO_SECURE = False  # 是否使用 https
+MINIO_BUCKET = "grading"
+MINIO_BASE_PREFIX = "raspi_img_data"
+
+DATA_HOST_URL = f"http://{MINIO_ENDPOINT}/{MINIO_BUCKET}/{MINIO_BASE_PREFIX}"
+
+
+
+# 卡片识别接口地址
+RECOGNIZE_API_URL = "http://192.168.77.249:18084/internal/any/card/image/rerank"
+DATA_PREFIX = "http://192.168.31.114/reverseSearch/sample/zip?file_path="
+# 定义默认的空结果

+ 6 - 22
app/api/camera.py

@@ -1,8 +1,6 @@
 from datetime import datetime
 
-from fastapi import APIRouter, HTTPException, Query, Request, Response
-
-from app.core.config import settings
+from fastapi import APIRouter, HTTPException, Query, Response
 from app.services import (
     CameraCaptureError,
     CameraUnavailableError,
@@ -56,7 +54,7 @@ def capture_image(
     methods=["GET", "POST"],
     summary="通过 MQTT 联动拍摄正反两张图片",
 )
-def capture_pair_via_mqtt(request: Request) -> dict[str, object]:
+def capture_pair_via_mqtt() -> dict[str, object]:
     """
     机械臂联动拍照接口。
 
@@ -64,11 +62,9 @@ def capture_pair_via_mqtt(request: Request) -> dict[str, object]:
     1. 本接口收到请求后,临时连接 MQTT。
     2. 发送 start 指令给机械臂。
     3. 收到 `id=1` / `id=2` 的拍照指令后,分别覆盖保存到静态目录。
-    4. 等待 `status=4` 作为流程结束信号。
-    5. 断开 MQTT,返回两张图片的静态访问地址。
-
-    返回 JSON 而不是直接返回图片,是因为这条流程一次会产出两张图,
-    更适合用 URL 的方式交给前端或其他服务继续处理。
+    4. 第一张图保存后,调用识别接口获取卡牌信息。
+    5. 等待 `status=4` 作为流程结束信号。
+    6. 上传两张图到 MinIO,并返回 MinIO URL 和识别结果。
     """
     mqtt_capture_service = get_mqtt_capture_service()
 
@@ -79,16 +75,4 @@ def capture_pair_via_mqtt(request: Request) -> dict[str, object]:
     except MqttCaptureError as exc:
         raise HTTPException(status_code=503, detail=str(exc)) from exc
 
-    front_url = str(
-        request.url_for("static", path=settings.static.front_relative_path.as_posix())
-    )
-    back_url = str(
-        request.url_for("static", path=settings.static.back_relative_path.as_posix())
-    )
-
-    return {
-        "request_id": result["request_id"],
-        "status": result["status"],
-        "front_url": front_url,
-        "back_url": back_url,
-    }
+    return result

+ 22 - 2
app/core/config.py

@@ -53,8 +53,8 @@ class StaticSettings:
 
     root_dir: Path = field(default_factory=lambda: PROJECT_ROOT / "static")
     capture_dir_name: str = "captures"
-    front_filename: str = "1.jpg"
-    back_filename: str = "2.jpg"
+    front_filename: str = "front.jpg"
+    back_filename: str = "back.jpg"
     index_filename: str = "index.html"
 
     @property
@@ -85,6 +85,25 @@ class StaticSettings:
         self.capture_dir.mkdir(parents=True, exist_ok=True)
 
 
+@dataclass(frozen=True)
+class CloudSettings:
+    """MinIO 与卡牌识别服务配置。"""
+
+    minio_endpoint: str = "192.168.77.249:9000"
+    minio_access_key: str = "pZEwCGnpNN05KPnmC2Yh"
+    minio_secret_key: str = "KfJRuWiv9pVxhIMcFqbkv8hZT9SnNTZ6LPx592D4"
+    minio_secure: bool = False
+    minio_bucket: str = "grading"
+    minio_base_prefix: str = "raspi_img_data"
+    recognize_api_url: str = "http://192.168.77.249:18084/internal/any/card/image/rerank"
+    recognize_timeout_seconds: float = 15.0
+    data_prefix: str = "http://192.168.31.114/reverseSearch/sample/zip?file_path="
+
+    @property
+    def minio_public_base_url(self) -> str:
+        return f"http://{self.minio_endpoint}/{self.minio_bucket}/{self.minio_base_prefix}"
+
+
 @dataclass(frozen=True)
 class Settings:
     """项目总配置。"""
@@ -93,6 +112,7 @@ class Settings:
     camera: CameraSettings = field(default_factory=CameraSettings)
     mqtt: MqttSettings = field(default_factory=MqttSettings)
     static: StaticSettings = field(default_factory=StaticSettings)
+    cloud: CloudSettings = field(default_factory=CloudSettings)
 
 
 settings = Settings()

+ 3 - 3
app/main.py

@@ -1,7 +1,7 @@
 import logging
 from contextlib import asynccontextmanager
 
-from fastapi import FastAPI, Request
+from fastapi import FastAPI
 from fastapi.responses import FileResponse
 from fastapi.staticfiles import StaticFiles
 
@@ -57,8 +57,8 @@ def read_root() -> FileResponse:
 
 
 @app.api_route("/capture-pair", methods=["GET", "POST"], include_in_schema=False)
-def capture_pair_alias(request: Request):
-    return capture_pair_via_mqtt(request)
+def capture_pair_alias():
+    return capture_pair_via_mqtt()
 
 
 @app.get("/health", summary="健康检查")

+ 109 - 9
app/services/mqtt_capture_service.py

@@ -2,8 +2,9 @@ import json
 import logging
 import threading
 import time
+from datetime import datetime
 from pathlib import Path
-from typing import Optional
+from typing import Any, Optional
 
 import paho.mqtt.client as mqtt
 
@@ -34,6 +35,7 @@ class MqttCaptureService:
     def __init__(self) -> None:
         self._mqtt = settings.mqtt
         self._static = settings.static
+        self._cloud = settings.cloud
         self._camera = get_camera_service()
 
     def capture_pair(self) -> dict[str, object]:
@@ -41,16 +43,18 @@ class MqttCaptureService:
             raise MqttCaptureBusyError("当前已有一个 MQTT 拍照任务正在执行,请稍后再试。")
 
         self._static.ensure_directories()
-        request_id = f"req-{int(time.time() * 1000)}"
         front_path = self._static.front_image_path
         back_path = self._static.back_image_path
-        state = {
+        request_id = f"req-{int(time.time() * 1000)}"
+        state: dict[str, Any] = {
             "connected": threading.Event(),
             "done": threading.Event(),
+            "recognized": threading.Event(),
             "error": "",
             "status": None,
             "front_saved": False,
             "back_saved": False,
+            "recognizedInfoDTO": None,
         }
 
         client = mqtt.Client(
@@ -136,6 +140,12 @@ class MqttCaptureService:
 
             if image_id == "1":
                 state["front_saved"] = True
+                threading.Thread(
+                    target=self._recognize_front_image,
+                    args=(front_path, state),
+                    daemon=True,
+                ).start()
+
             if image_id == "2":
                 state["back_saved"] = True
 
@@ -185,16 +195,17 @@ class MqttCaptureService:
                 raise MqttCaptureError(f"流程未正常结束,最终 status={state['status']}")
 
             if not state["front_saved"] or not front_path.exists():
-                raise MqttCaptureError("没有拿到 1.jpg")
+                raise MqttCaptureError("没有拿到正面图片")
 
             if not state["back_saved"] or not back_path.exists():
-                raise MqttCaptureError("没有拿到 2.jpg")
+                raise MqttCaptureError("没有拿到背面图片")
+
+            state["recognized"].wait(timeout=self._cloud.recognize_timeout_seconds)
 
             return {
-                "request_id": request_id,
-                "status": state["status"],
-                "front_path": front_path,
-                "back_path": back_path,
+                "card_front_url": self._upload_image_to_cloud(front_path, "front"),
+                "card_back_url": self._upload_image_to_cloud(back_path, "back"),
+                "recognizedInfoDTO": state["recognizedInfoDTO"],
             }
         finally:
             try:
@@ -230,6 +241,95 @@ class MqttCaptureService:
         client.publish(self._mqtt.camera_response_topic, json.dumps(payload), qos=1)
         logger.info("已发送拍照回执: %s", payload)
 
+    def _recognize_front_image(self, front_path: Path, state: dict[str, Any]) -> None:
+        try:
+            state["recognizedInfoDTO"] = self._recognize_card_info(front_path)
+        finally:
+            state["recognized"].set()
+
+    def _upload_image_to_cloud(self, local_file_path: Path, side: str) -> Optional[str]:
+        if not local_file_path.exists():
+            return None
+
+        try:
+            from minio import Minio
+        except ImportError:
+            logger.warning("未安装 minio 依赖,跳过上传。")
+            return None
+
+        try:
+            minio_client = Minio(
+                self._cloud.minio_endpoint,
+                access_key=self._cloud.minio_access_key,
+                secret_key=self._cloud.minio_secret_key,
+                secure=self._cloud.minio_secure,
+            )
+            timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S_%f")
+            object_name = f"raspi_{side}_{timestamp}.jpg"
+            cloud_path = f"{self._cloud.minio_base_prefix}/{object_name}"
+
+            minio_client.fput_object(
+                self._cloud.minio_bucket,
+                cloud_path,
+                str(local_file_path),
+                content_type="image/jpeg",
+            )
+            return f"{self._cloud.minio_public_base_url}/{object_name}"
+        except Exception:
+            logger.warning("上传图片到 MinIO 失败: %s", local_file_path, exc_info=True)
+            return None
+
+    def _recognize_card_info(self, local_file_path: Path) -> Optional[dict[str, str]]:
+        if not local_file_path.exists():
+            return None
+
+        try:
+            import requests
+        except ImportError:
+            logger.warning("未安装 requests 依赖,跳过卡牌识别。")
+            return None
+
+        try:
+            with local_file_path.open("rb") as image_file:
+                response = requests.post(
+                    self._cloud.recognize_api_url,
+                    files={
+                        "imgFile": (
+                            local_file_path.name,
+                            image_file,
+                            "image/jpeg",
+                        )
+                    },
+                    timeout=self._cloud.recognize_timeout_seconds,
+                )
+
+            if response.status_code != 200:
+                logger.warning("卡牌识别接口返回异常状态码: %s", response.status_code)
+                return None
+
+            result_list = response.json()
+            if not result_list or not isinstance(result_list, list):
+                logger.warning("卡牌识别接口返回了空结果或非法格式。")
+                return None
+
+            detail = (result_list[0] or {}).get("detail") or {}
+            path = detail.get("path") or ""
+            if path:
+                path = f"{self._cloud.data_prefix}{path}"
+
+            return {
+                "year": detail.get("year", ""),
+                "series": detail.get("series", ""),
+                "cardSet": detail.get("cardset", ""),
+                "player": detail.get("player", ""),
+                "confidentialId": detail.get("no", ""),
+                "limitId": "",
+                "path": path,
+            }
+        except Exception:
+            logger.warning("卡牌识别请求失败: %s", local_file_path, exc_info=True)
+            return None
+
 
 def get_mqtt_capture_service() -> MqttCaptureService:
     return MqttCaptureService()

+ 3 - 1
requirement.txt

@@ -1 +1,3 @@
-paho-mqtt==1.6.1
+paho-mqtt==1.6.1
+minio==7.2.7
+requests==2.32.3

+ 35 - 4
static/index.html

@@ -136,6 +136,21 @@
             color: var(--accent-dark);
         }
 
+        .info-card {
+            margin-top: 20px;
+        }
+
+        .info-box {
+            min-height: 180px;
+            border-radius: 14px;
+            border: 1px dashed #c7b39b;
+            background: #f7f1e7;
+            padding: 16px;
+            white-space: pre-wrap;
+            line-height: 1.7;
+            overflow: auto;
+        }
+
         @media (max-width: 800px) {
             .result {
                 grid-template-columns: 1fr;
@@ -151,7 +166,7 @@
 <div class="page">
     <div class="header">
         <h1>树莓派正反面拍照</h1>
-        <p>点击按钮后,页面会请求 <code>/capture-pair</code>,等待机械臂流程完成,再展示返回的两张图片。</p>
+        <p>点击按钮后,页面会请求 <code>/capture-pair</code>,等待机械臂流程完成,然后展示 MinIO 图片链接和第一张图的识别结果。</p>
     </div>
 
     <div class="toolbar">
@@ -178,6 +193,11 @@
             <div class="link"><a id="backLink" href="#" target="_blank"></a></div>
         </div>
     </div>
+
+    <div class="card info-card">
+        <h2>识别结果</h2>
+        <div class="info-box" id="recognizedInfo">还没有识别结果</div>
+    </div>
 </div>
 
 <script>
@@ -189,6 +209,7 @@
     const backLink = document.getElementById("backLink");
     const frontPlaceholder = document.getElementById("frontPlaceholder");
     const backPlaceholder = document.getElementById("backPlaceholder");
+    const recognizedInfo = document.getElementById("recognizedInfo");
 
     function setStatus(text) {
         statusText.textContent = text;
@@ -215,9 +236,19 @@
                 throw new Error(data.detail || "请求失败");
             }
 
-            showImage(frontImage, frontLink, frontPlaceholder, data.front_url);
-            showImage(backImage, backLink, backPlaceholder, data.back_url);
-            setStatus(`拍照完成,status=${data.status},request_id=${data.request_id}`);
+            if (data.card_front_url) {
+                showImage(frontImage, frontLink, frontPlaceholder, data.card_front_url);
+            }
+
+            if (data.card_back_url) {
+                showImage(backImage, backLink, backPlaceholder, data.card_back_url);
+            }
+
+            recognizedInfo.textContent = data.recognizedInfoDTO
+                ? JSON.stringify(data.recognizedInfoDTO, null, 2)
+                : "未获取到识别结果";
+
+            setStatus("拍照完成");
         } catch (error) {
             setStatus(`拍照失败:${error.message}`);
         } finally {