Dish_Recognition/service/result_api.py

314 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# coding=utf-8
"""识别结果上报:先上传图片拿到 URL,再 POST 到菜品管理系统。"""
import copy
import json
import os
import threading
import time
from datetime import datetime
from urllib.parse import urlparse, urlunparse
try:
import requests
except ImportError:
requests = None
from central_server.media_store import MediaStore, is_http_url
from dao.settings import (
DISH_BASE_CODE,
DISH_BASE_NAME,
DISH_DEVICE_CODE,
DISH_MGMT_API_KEY,
DISH_MGMT_RESULT_API_URL,
)
# ---------- 后续你只需改这里 ----------
# 默认发往菜品管理系统:POST /api/public/recognition-orders
RESULT_API_URL = os.environ.get('DISH_RESULT_API_URL', DISH_MGMT_RESULT_API_URL)
# 默认真实上报;本地联调可设 DISH_RESULT_API_MOCK=1
RESULT_API_MOCK = os.environ.get('DISH_RESULT_API_MOCK', '0') == '1'
RESULT_API_TIMEOUT = float(os.environ.get('DISH_RESULT_API_TIMEOUT', '10'))
RESULT_API_BASE_ID = os.environ.get('DISH_RESULT_BASE_ID') or os.environ.get('DISH_BASE_CODE', DISH_BASE_CODE)
RESULT_API_BASE_NAME = os.environ.get('DISH_RESULT_BASE_NAME') or DISH_BASE_NAME
RESULT_API_DEVICE_CODE = os.environ.get('DISH_RESULT_DEVICE_CODE') or DISH_DEVICE_CODE
RESULT_API_TOKEN = os.environ.get('DISH_API_TOKEN', '').strip()
# 管理系统鉴权:优先专用结果 Key,否则用基地接口同一把 Key
RESULT_API_KEY = (
os.environ.get('DISH_RESULT_API_KEY', '').strip()
or os.environ.get('DISH_MGMT_API_KEY', '').strip()
or (DISH_MGMT_API_KEY or '').strip()
)
# 上报前把本机路径转成可访问 HTTP URL(YOLO 裁剪图必须)
UPLOAD_BEFORE_REPORT = os.environ.get('DISH_UPLOAD_BEFORE_REPORT', '1') == '1'
MEDIA_UPLOAD_TIMEOUT = float(os.environ.get('DISH_MEDIA_UPLOAD_TIMEOUT', '20'))
# 仅当显式配置了可连的上传地址时才走远程 upload;默认用本机 media server
_EXPLICIT_MEDIA_UPLOAD = os.environ.get('DISH_MEDIA_UPLOAD_URL', '').strip()
# ------------------------------------
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_MOCK_DIR = os.path.join(_PROJECT_ROOT, 'api_mock_results')
def _api_origin(result_url):
parsed = urlparse(result_url or '')
if not parsed.scheme or not parsed.netloc:
return 'http://127.0.0.1:9000'
return urlunparse((parsed.scheme, parsed.netloc, '', '', '', ''))
def default_media_upload_url(result_url=None):
"""仅返回显式配置的远程上传地址;未配置则用本机 media server。"""
return _EXPLICIT_MEDIA_UPLOAD or ''
def _as_score(value):
if value is None:
return None
try:
return round(float(value), 4)
except (TypeError, ValueError):
return None
def _format_dish_text(is_unknown, name, score, suggested_name):
if is_unknown:
return '低于阈值,建议人工确认'
if name is not None and score is not None:
return f'识别为{name},相似度 {score}'
if name:
return f'识别为{name}'
return ''
def build_result_payload(
source_image,
dishes,
labeled_image=None,
base_id=None,
base_name=None,
device_code=None,
request_id=None,
):
"""
管理系统 recognition-orders 约定格式:
{
request_id, base_name, device_code, capture_time,
source_image, labeled_image, dish_count,
dishes: [{index, name, score, is_unknown, suggested_name, box, crop_image, text}]
}
"""
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
day = datetime.now().strftime('%Y%m%d')
request_id = request_id or f"REQ_{day}_{int(time.time() * 1000) % 1000000:06d}"
items = []
for i, d in enumerate(dishes or [], 1):
is_unknown = bool(d.get('is_unknown'))
name = None if is_unknown else (d.get('label') or d.get('name'))
score = _as_score(d.get('score'))
suggested = d.get('suggested_name')
if suggested is None:
suggested = d.get('suggested_category')
if not is_unknown:
suggested = None
items.append({
'index': int(d.get('index') or i),
'name': name,
'score': score,
'is_unknown': is_unknown,
'suggested_name': suggested,
'box': d.get('box'),
'crop_image': d.get('crop_image'),
'text': _format_dish_text(is_unknown, name, score, suggested),
})
return {
'request_id': request_id,
'base_name': (base_name or RESULT_API_BASE_NAME or '').strip(),
'device_code': (device_code or RESULT_API_DEVICE_CODE or base_id or RESULT_API_BASE_ID or '').strip(),
'capture_time': now,
'source_image': source_image,
'labeled_image': labeled_image,
'dish_count': len(items),
'dishes': items,
}
class ResultApiClient:
def __init__(self, api_url=None, mock=None, timeout=None, base_id=None,
base_name=None, device_code=None,
media_upload_url=None, upload_before_report=None, token=None, api_key=None):
self.api_url = api_url or RESULT_API_URL
self.mock = RESULT_API_MOCK if mock is None else mock
self.timeout = timeout or RESULT_API_TIMEOUT
self.base_id = base_id or RESULT_API_BASE_ID
self.base_name = base_name or RESULT_API_BASE_NAME
self.device_code = device_code or RESULT_API_DEVICE_CODE or self.base_id
if media_upload_url is None:
self.media_upload_url = default_media_upload_url(self.api_url)
else:
self.media_upload_url = media_upload_url or ''
self.upload_before_report = (
UPLOAD_BEFORE_REPORT if upload_before_report is None else bool(upload_before_report)
)
self.token = RESULT_API_TOKEN if token is None else (token or '')
self.api_key = RESULT_API_KEY if api_key is None else (api_key or '')
self._store = MediaStore()
os.makedirs(_MOCK_DIR, exist_ok=True)
def _refresh_media_store(self):
"""确保本机 media HTTP 服务已启动,并用公网/局域网前缀生成 URL。"""
try:
from service.media_server import ensure_media_server, get_public_base
ensure_media_server()
self._store.public_base = get_public_base()
except Exception as exc:
print(f'[WARN] media server: {exc}')
def _headers(self):
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Base-Code': self.base_id or self.device_code or '',
}
if self.api_key:
headers['X-API-Key'] = self.api_key
if self.token:
headers['Authorization'] = f'Bearer {self.token}'
return headers
def send(self, source_image, dishes, labeled_image=None, async_mode=True):
payload = build_result_payload(
source_image=source_image,
dishes=dishes,
labeled_image=labeled_image,
base_id=self.base_id,
base_name=self.base_name,
device_code=self.device_code,
)
if not payload.get('dishes'):
print('[API] skip report: empty dishes')
payload['skipped'] = True
payload['ok'] = True
return payload
to_send = copy.deepcopy(payload)
if async_mode:
threading.Thread(target=self._post, args=(to_send,), daemon=True).start()
return payload
return self._post(to_send)
def _post(self, payload):
try:
payload = self._ensure_remote_urls(payload)
except Exception as exc:
print(f'[API] media prepare failed: {exc}')
if self.mock:
return self._mock_save(payload)
if requests is None:
print('[WARN] requests 未安装,回退模拟保存')
return self._mock_save(payload)
try:
resp = requests.post(
self.api_url, json=payload, timeout=self.timeout, headers=self._headers(),
)
ok = 200 <= resp.status_code < 300
print(f"[API] POST {self.api_url} -> {resp.status_code}")
if not ok:
print(f"[API] request: {json.dumps(payload, ensure_ascii=False)[:1200]}")
print(f"[API] response: {(resp.text or '')[:500]}")
return {'ok': ok, 'status_code': resp.status_code, 'payload': payload, 'response': resp.text}
except Exception as exc:
print(f"[API] POST failed: {exc}")
print(f"[API] request: {json.dumps(payload, ensure_ascii=False)[:1200]}")
saved = self._mock_save(payload)
saved['ok'] = False
saved['error'] = str(exc)
return saved
def _ensure_remote_urls(self, payload):
"""YOLO 裁剪图等本机路径 → 可访问 HTTP URL(本机 media server)。"""
if not self.upload_before_report:
return payload
self._refresh_media_store()
request_id = payload.get('request_id')
base_id = payload.get('device_code') or payload.get('base_name') or self.device_code or self.base_id
payload['source_image'] = self._to_url(
payload.get('source_image'), 'source', base_id, request_id,
)
payload['labeled_image'] = self._to_url(
payload.get('labeled_image'), 'labeled', base_id, request_id,
)
for dish in payload.get('dishes') or []:
dish['crop_image'] = self._to_url(
dish.get('crop_image'), 'crop', base_id, request_id,
)
return payload
def _to_url(self, path_or_url, media_type, base_id, request_id):
if not path_or_url:
return path_or_url
if is_http_url(path_or_url):
return path_or_url
local = os.path.abspath(path_or_url) if not os.path.isabs(path_or_url) else path_or_url
if not os.path.isfile(local):
return path_or_url
# 显式远程上传优先;失败则回落本机 media server
if self.media_upload_url and requests is not None and not self.mock:
url = self._http_upload(local, media_type, base_id, request_id)
if url:
return url
saved = self._store.save_file(
local, media_type=media_type, base_id=base_id, request_id=request_id,
)
if saved and saved.get('url'):
print(f"[API] {media_type} -> {saved['url']}")
return saved['url']
return path_or_url
def _http_upload(self, file_path, media_type, base_id, request_id):
if not self.media_upload_url:
return None
try:
with open(file_path, 'rb') as fh:
files = {'file': (os.path.basename(file_path), fh, 'application/octet-stream')}
data = {
'type': media_type,
'base_id': base_id or '',
'request_id': request_id or '',
}
headers = {'X-Base-Code': base_id or ''}
if self.api_key:
headers['X-API-Key'] = self.api_key
if self.token:
headers['Authorization'] = f'Bearer {self.token}'
resp = requests.post(
self.media_upload_url,
files=files,
data=data,
headers=headers,
timeout=MEDIA_UPLOAD_TIMEOUT,
)
if not (200 <= resp.status_code < 300):
print(f"[API] upload {media_type} -> {resp.status_code} {resp.text[:200]}")
return None
body = resp.json() if resp.content else {}
data = body.get('data') if isinstance(body, dict) else None
url = None
if isinstance(data, dict):
url = data.get('url')
if not url and isinstance(body, dict):
url = body.get('url')
return url
except Exception as exc:
print(f"[API] upload {media_type} failed: {exc}")
return None
def _mock_save(self, payload):
name = f"{payload['request_id']}.json"
path = os.path.join(_MOCK_DIR, name)
with open(path, 'w', encoding='utf-8') as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
print(f"[API-MOCK] saved {path}")
print(f"[API-MOCK] dishes={payload['dish_count']} url={self.api_url}")
return {'ok': True, 'mock': True, 'path': path, 'payload': payload}