Dish_Recognition/service/result_api.py

455 lines
18 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 dish_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'))
# 上传接口仍打云 dish_server(:9100);返回 URL 应为 https://cpsb.ruimeida.cc/tpweb/...
_DEFAULT_CLOUD_HOST = 'http://116.147.40.47:9100'
_DEFAULT_CLOUD_UPLOAD = f'{_DEFAULT_CLOUD_HOST}/api/dish/media/upload'
_TPWEB_PUBLIC_BASE = os.environ.get(
'DISH_MEDIA_PUBLIC_BASE', 'https://cpsb.ruimeida.cc/tpweb'
).rstrip('/')
if 'DISH_MEDIA_UPLOAD_URL' in os.environ:
_EXPLICIT_MEDIA_UPLOAD = os.environ.get('DISH_MEDIA_UPLOAD_URL', '').strip()
else:
_EXPLICIT_MEDIA_UPLOAD = _DEFAULT_CLOUD_UPLOAD
# 识别结果默认只打管理系统;需要云落盘时再设 DISH_CLOUD_RESULT_URL
CLOUD_RESULT_API_URL = os.environ.get('DISH_CLOUD_RESULT_URL', '').strip()
# ------------------------------------
def _is_private_http_url(url):
"""局域网/本机 HTTP,管理系统(公网 HTTPS)打不开。"""
text = (url or '').strip().lower()
if not text.startswith('http://') and not text.startswith('https://'):
return False
try:
host = (urlparse(text).hostname or '').lower()
except Exception:
return False
if host in ('localhost', '127.0.0.1', '0.0.0.0'):
return True
if host.startswith('192.168.') or host.startswith('10.'):
return True
# 172.16.0.0 – 172.31.255.255
if host.startswith('172.'):
try:
second = int(host.split('.')[1])
if 16 <= second <= 31:
return True
except Exception:
pass
return False
def _to_tpweb_public_url(url):
"""把旧 http://IP:9100/media|tpweb/... 归一成 HTTPS tpweb 地址。"""
if not url:
return url
text = str(url).strip()
prefixes = (
'http://116.147.40.47:9100/media/',
'https://116.147.40.47:9100/media/',
'http://116.147.40.47:9100/tpweb/',
'https://116.147.40.47:9100/tpweb/',
'http://cpsb.ruimeida.cc/tpweb/',
'http://cpsb.ruimeida.cc/media/',
'https://cpsb.ruimeida.cc/media/',
)
for prefix in prefixes:
if text.startswith(prefix):
return f'{_TPWEB_PUBLIC_BASE}/{text[len(prefix):]}'
return text
_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):
"""默认云微服务上传地址;失败时 result_api 会回落本机 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),
})
bid = (base_id or RESULT_API_BASE_ID or '').strip()
return {
'request_id': request_id,
'base_id': bid,
'base_name': (base_name or RESULT_API_BASE_NAME or '').strip(),
'device_code': (device_code or RESULT_API_DEVICE_CODE or bid 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,
cloud_result_url=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.cloud_result_url = (
CLOUD_RESULT_API_URL if cloud_result_url is None else (cloud_result_url 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)
# 1) 云微服务落盘(图已在云上,结果 JSON 一并存)
cloud_ok = self._post_cloud_result(payload)
# 2) 菜品管理系统(业务侧)
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,
'cloud_ok': cloud_ok,
}
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)
saved['cloud_ok'] = cloud_ok
return saved
def _post_cloud_result(self, payload):
"""识别结果写入云 dish_server(data/results/*.json)。"""
url = (self.cloud_result_url or '').strip()
if not url:
return None
# 与管理系统 URL 相同时避免重复 POST
if url.rstrip('/') == (self.api_url or '').rstrip('/'):
return None
body = copy.deepcopy(payload)
if not body.get('base_id'):
body['base_id'] = self.base_id or body.get('device_code') or ''
try:
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Base-Code': body.get('base_id') or self.base_id or '',
}
if self.token:
headers['Authorization'] = f'Bearer {self.token}'
resp = requests.post(url, json=body, timeout=self.timeout, headers=headers)
ok = 200 <= resp.status_code < 300
print(f"[API] CLOUD POST {url} -> {resp.status_code}")
if not ok:
print(f"[API] cloud response: {(resp.text or '')[:300]}")
return ok
except Exception as exc:
print(f"[API] CLOUD POST failed: {exc}")
return False
def _ensure_remote_urls(self, payload):
"""本地图片路径 → 云 tpweb HTTPS URL(不上报局域网地址)。"""
if not self.upload_before_report:
return payload
print(
f"[API] media upload -> {self.media_upload_url or '(disabled)'} "
f"| public={_TPWEB_PUBLIC_BASE} | mock={self.mock}"
)
# 仅本机兜底服务需要;失败回落时不会再用局域网 URL 上报
try:
self._refresh_media_store()
except Exception as exc:
print(f'[WARN] media server: {exc}')
request_id = payload.get('request_id')
base_id = (
payload.get('base_id')
or 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):
fixed = _to_tpweb_public_url(path_or_url)
if _is_private_http_url(fixed):
print(f'[API] WARN 丢弃局域网图片地址({media_type}): {fixed}')
return None
return fixed
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
# 已配置云上传:必须上传成功,禁止回落 192.168 地址
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
# 重试一次
url = self._http_upload(local, media_type, base_id, request_id)
if url:
return url
print(
f'[API] ERROR {media_type} 云上传失败,不会使用局域网 URL 上报。'
f'请检查云 dish_server 与网络: {self.media_upload_url}'
)
return None
# 未配置云上传时才允许本机 media(仅本地联调)
saved = self._store.save_file(
local, media_type=media_type, base_id=base_id, request_id=request_id,
)
if saved and saved.get('url'):
url = _to_tpweb_public_url(saved['url'])
if _is_private_http_url(url):
print(f'[API] WARN 本机 media URL 为局域网,管理系统不可用: {url}')
return None
print(f"[API] {media_type} -> {url}")
return 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') or data.get('path')
if not url and isinstance(body, dict):
url = body.get('url')
# 相对路径 → HTTPS tpweb;旧 http://IP:9100/media 也改写
if url and not is_http_url(url):
url = f"{_TPWEB_PUBLIC_BASE}/{str(url).lstrip('/')}"
url = _to_tpweb_public_url(url)
if url:
print(f"[API] {media_type} -> {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}