修改AI生图接口、修改设备连接UI、修改LocalDimming逻辑和UI
This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
"""AI 图片生成服务:后端请求 + 本地缓存管理。
|
||||
|
||||
后端接口(测试环境):
|
||||
POST {API_BASE_URL}{API_PATH}
|
||||
body: {"user_message": str, "session_id": str}
|
||||
resp: {"code": 200, "message": "", "data": {"imageUrl": "..."}}
|
||||
后端接口(生产/测试环境):
|
||||
POST {API_BASE_URL}{API_GENERATE_PATH}
|
||||
body: {"user_message": str, "session_id": str, "upload_image_url"?: str}
|
||||
resp: {"code": 200, "message": "", "data": {"imageUrl": "..."}}
|
||||
POST {API_BASE_URL}{API_UPLOAD_PATH} # multipart/form-data, field: file
|
||||
resp: {"code": 200, "message": "", "data": {"upload_image_url": "..."}}
|
||||
|
||||
带 ``upload_image_url`` 启用"图生图"模式;多轮对话需将上一轮返回的 imageUrl
|
||||
作为下一轮请求的 upload_image_url(由 panel 通过会话级缓存自动维护)。
|
||||
|
||||
缓存目录:``settings/ai_image_cache/``,每张图片有同名的 ``.json`` 侧车记录。
|
||||
"""
|
||||
@@ -41,10 +46,20 @@ _META_SUFFIX = ".json"
|
||||
_SUPPORTED_IMG_EXT = (".png", ".jpg", ".jpeg", ".bmp", ".webp")
|
||||
|
||||
# 测试环境后端
|
||||
# API_BASE_URL = "http://10.201.44.70:9018/ai-agent/"
|
||||
# API_BASE_URL = "http://10.201.44.70:9008/ai-agent/"
|
||||
API_BASE_URL = "https://rd-mokadisplay.tcl.com/ai-agent/"
|
||||
API_PATH = "api/v1/pqtest/generate"
|
||||
API_TIMEOUT = 300.0 # 后端最长 60s,留余量
|
||||
API_GENERATE_PATH = "api/v1/pqtest/generate"
|
||||
API_UPLOAD_PATH = "api/v1/pqtest/upload"
|
||||
API_TIMEOUT = 300.0 # 后端最长 120s,留余量
|
||||
API_UPLOAD_TIMEOUT = 60.0
|
||||
|
||||
# 上传接口限制(来自接口文档)
|
||||
UPLOAD_MAX_BYTES = 10 * 1024 * 1024
|
||||
UPLOAD_MAX_PIXELS = 4096
|
||||
UPLOAD_ALLOWED_EXT = (".png", ".jpg", ".jpeg")
|
||||
|
||||
# 兼容旧名(如其他模块仍引用)
|
||||
API_PATH = API_GENERATE_PATH
|
||||
|
||||
# 进程级会话 id(多轮对话需保持一致),可通过 ``reset_session`` 重置
|
||||
_session_id: str = str(uuid.uuid4())
|
||||
@@ -133,9 +148,9 @@ class AIImageRecord:
|
||||
# ---------- 后端 API ----------
|
||||
|
||||
|
||||
def _api_endpoint() -> str:
|
||||
def _api_endpoint(path: str = API_GENERATE_PATH) -> str:
|
||||
base = API_BASE_URL if API_BASE_URL.endswith("/") else API_BASE_URL + "/"
|
||||
return base + API_PATH.lstrip("/")
|
||||
return base + path.lstrip("/")
|
||||
|
||||
|
||||
def _pretty_json_text(value) -> str:
|
||||
@@ -150,22 +165,33 @@ def _pretty_json_text(value) -> str:
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def _call_pqtest_generate(user_message: str, session_id: str, timeout: float = API_TIMEOUT) -> str:
|
||||
"""调用后端 ``api/v1/pqtest/generate``,返回 imageUrl。失败抛异常。"""
|
||||
payload = json.dumps(
|
||||
{"user_message": user_message,
|
||||
"session_id": session_id},
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
def _call_pqtest_generate(
|
||||
user_message: str,
|
||||
session_id: str,
|
||||
upload_image_url: Optional[str] = None,
|
||||
timeout: float = API_TIMEOUT,
|
||||
) -> str:
|
||||
"""调用后端 ``api/v1/pqtest/generate``,返回 imageUrl。失败抛异常。
|
||||
|
||||
``upload_image_url`` 传入时启用"图生图"模式。
|
||||
"""
|
||||
body: dict = {"user_message": user_message, "session_id": session_id}
|
||||
if upload_image_url:
|
||||
body["upload_image_url"] = upload_image_url
|
||||
payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||||
request_headers = {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "pqAutomationApp/1.0",
|
||||
}
|
||||
endpoint = _api_endpoint()
|
||||
endpoint = _api_endpoint(API_GENERATE_PATH)
|
||||
logger.info(
|
||||
"[AIImage] 请求生成 sid=%s prompt_len=%d prompt=%r",
|
||||
_mask_sid(session_id), len(user_message or ""), _truncate(user_message),
|
||||
"[AIImage] 请求生成 sid=%s mode=%s prompt_len=%d prompt=%r ref=%s",
|
||||
_mask_sid(session_id),
|
||||
"img2img" if upload_image_url else "txt2img",
|
||||
len(user_message or ""),
|
||||
_truncate(user_message),
|
||||
upload_image_url or "-",
|
||||
)
|
||||
logger.info(
|
||||
"[AIImage][REQUEST]\nendpoint=%s\nmethod=POST\ntimeout=%.1fs\nheaders=%s\nbody=%s",
|
||||
@@ -250,6 +276,137 @@ def _call_pqtest_generate(user_message: str, session_id: str, timeout: float = A
|
||||
return image_url
|
||||
|
||||
|
||||
def _call_pqtest_upload(file_path: str, timeout: float = API_UPLOAD_TIMEOUT, auto_resize: bool = True) -> str:
|
||||
"""以 multipart/form-data 上传本地图片,返回 ``upload_image_url``。失败抛异常。
|
||||
|
||||
参数:
|
||||
auto_resize: True 时,若图片超过 4096×4096 或 10MB 则自动缩放/重压
|
||||
"""
|
||||
if not file_path or not os.path.isfile(file_path):
|
||||
raise FileNotFoundError(f"图片文件不存在: {file_path}")
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
if ext not in UPLOAD_ALLOWED_EXT:
|
||||
raise ValueError(f"不支持的图片格式 ({ext}),仅支持 PNG/JPG/JPEG")
|
||||
|
||||
try:
|
||||
with Image.open(file_path) as img:
|
||||
iw, ih = img.size
|
||||
except Exception as exc:
|
||||
raise ValueError(f"无法读取图片: {exc}") from exc
|
||||
|
||||
# 检查大小,如需则缩放
|
||||
size = os.path.getsize(file_path)
|
||||
needs_resize = (iw > UPLOAD_MAX_PIXELS or ih > UPLOAD_MAX_PIXELS or size > UPLOAD_MAX_BYTES)
|
||||
|
||||
if needs_resize:
|
||||
if not auto_resize:
|
||||
if iw > UPLOAD_MAX_PIXELS or ih > UPLOAD_MAX_PIXELS:
|
||||
raise ValueError(f"分辨率超过 4096×4096(当前 {iw}×{ih})")
|
||||
else:
|
||||
raise ValueError(f"图片超过 10MB 限制(当前 {size/1024/1024:.2f}MB)")
|
||||
|
||||
# 自动缩放:等比例缩放至 4096×4096 以内
|
||||
logger.info("[AIImage][UPLOAD] 自动缩放 %dx%d (%.1fMB) 至 ≤4096×4096",
|
||||
iw, ih, size/1024/1024)
|
||||
scale = min(UPLOAD_MAX_PIXELS / iw, UPLOAD_MAX_PIXELS / ih, 1.0)
|
||||
new_w, new_h = max(1, int(iw * scale)), max(1, int(ih * scale))
|
||||
|
||||
with Image.open(file_path) as img:
|
||||
img_resized = img.resize((new_w, new_h), Image.LANCZOS)
|
||||
# 重压至 10MB 以下
|
||||
# 首先尝试原格式
|
||||
tmp_io = BytesIO()
|
||||
fmt = "PNG" if ext == ".png" else "JPEG"
|
||||
save_kw = {"format": fmt}
|
||||
img_resized.save(tmp_io, **save_kw)
|
||||
tmp_bytes = tmp_io.getvalue()
|
||||
|
||||
if len(tmp_bytes) <= UPLOAD_MAX_BYTES:
|
||||
file_bytes = tmp_bytes
|
||||
else:
|
||||
# 原格式太大,转换为 JPEG 并压缩
|
||||
logger.info("[AIImage][UPLOAD] 原格式超过限制,转为 JPEG 并压缩")
|
||||
quality = 95
|
||||
while quality >= 50:
|
||||
tmp_io = BytesIO()
|
||||
img_resized.save(tmp_io, format="JPEG", quality=quality, optimize=True)
|
||||
tmp_bytes = tmp_io.getvalue()
|
||||
if len(tmp_bytes) <= UPLOAD_MAX_BYTES:
|
||||
file_bytes = tmp_bytes
|
||||
break
|
||||
quality -= 5
|
||||
else:
|
||||
# 即使 quality=50 还是太大,也用这个版本上传(后端会处理)
|
||||
file_bytes = tmp_bytes
|
||||
|
||||
logger.info("[AIImage][UPLOAD] 缩放完成 %dx%d (%.1fMB)",
|
||||
new_w, new_h, len(file_bytes)/1024/1024)
|
||||
iw, ih = new_w, new_h
|
||||
else:
|
||||
with open(file_path, "rb") as f:
|
||||
file_bytes = f.read()
|
||||
|
||||
mime = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
|
||||
boundary = "----pqAuto" + uuid.uuid4().hex
|
||||
filename = os.path.basename(file_path)
|
||||
crlf = b"\r\n"
|
||||
body = b"".join([
|
||||
b"--", boundary.encode("ascii"), crlf,
|
||||
b'Content-Disposition: form-data; name="file"; filename="',
|
||||
filename.encode("utf-8"), b'"', crlf,
|
||||
b"Content-Type: ", mime.encode("ascii"), crlf, crlf,
|
||||
file_bytes, crlf,
|
||||
b"--", boundary.encode("ascii"), b"--", crlf,
|
||||
])
|
||||
|
||||
endpoint = _api_endpoint(API_UPLOAD_PATH)
|
||||
headers = {
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "pqAutomationApp/1.0",
|
||||
"Content-Length": str(len(body)),
|
||||
}
|
||||
logger.info(
|
||||
"[AIImage][UPLOAD] file=%s size=%dB mime=%s wh=%dx%d -> %s",
|
||||
filename, len(file_bytes), mime, iw, ih, endpoint,
|
||||
)
|
||||
request = Request(endpoint, data=body, method="POST", headers=headers)
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
with urlopen(request, timeout=timeout) as response:
|
||||
raw = response.read()
|
||||
http_status = response.status
|
||||
raw_text = raw.decode("utf-8", errors="replace")
|
||||
logger.info("[AIImage][UPLOAD_RESP]\nstatus=%s\nbody=%s",
|
||||
http_status, _pretty_json_text(raw_text))
|
||||
except HTTPError as exc:
|
||||
err_raw = b""
|
||||
try:
|
||||
err_raw = exc.read() or b""
|
||||
except Exception:
|
||||
pass
|
||||
err_text = err_raw.decode("utf-8", errors="replace") if err_raw else ""
|
||||
logger.error("[AIImage][UPLOAD_ERR] status=%s reason=%s body=%s",
|
||||
getattr(exc, "code", "?"), str(exc), _pretty_json_text(err_text))
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("[AIImage][UPLOAD_ERR] %s: %s", type(exc).__name__, exc)
|
||||
raise
|
||||
elapsed = time.monotonic() - t0
|
||||
try:
|
||||
result = json.loads(raw.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"上传接口返回非 JSON:{raw_text}") from exc
|
||||
code = result.get("code")
|
||||
message = result.get("message") or ""
|
||||
data = result.get("data") or {}
|
||||
url = (data.get("upload_image_url") or "").strip()
|
||||
if code != 200 or not url:
|
||||
raise RuntimeError(f"上传失败 code={code} msg={message or '未知错误'}")
|
||||
logger.info("[AIImage][UPLOAD_OK] elapsed=%.2fs url=%s", elapsed, url)
|
||||
return url
|
||||
|
||||
|
||||
# ---------- 缓存路径工具 ----------
|
||||
|
||||
|
||||
@@ -504,6 +661,7 @@ def request_image_async(
|
||||
base_dir: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
cancel_event: Optional[threading.Event] = None,
|
||||
upload_image_url: Optional[str] = None,
|
||||
) -> threading.Thread:
|
||||
"""在后台线程调用后端 API → 下载图片 → 写入缓存 → 回调。
|
||||
|
||||
@@ -511,24 +669,33 @@ def request_image_async(
|
||||
切回主线程,请在回调内部自行用 ``root.after(0, ...)``。
|
||||
|
||||
``session_id`` 留空则使用进程级会话 id(保证多轮对话上下文)。
|
||||
``upload_image_url`` 传入后启用"图生图"模式。
|
||||
"""
|
||||
|
||||
sid = session_id or get_session_id()
|
||||
cancel = cancel_event
|
||||
ref_url = (upload_image_url or "").strip() or None
|
||||
|
||||
def _worker():
|
||||
try:
|
||||
if cancel is not None and cancel.is_set():
|
||||
logger.info("[AIImage] 任务已取消(请求前) sid=%s", _mask_sid(sid))
|
||||
return
|
||||
image_url = _call_pqtest_generate(prompt, sid)
|
||||
image_url = _call_pqtest_generate(prompt, sid, upload_image_url=ref_url)
|
||||
if cancel is not None and cancel.is_set():
|
||||
logger.info("[AIImage] 任务已取消(生成后) sid=%s", _mask_sid(sid))
|
||||
return
|
||||
extra = {
|
||||
"source": "ai-api",
|
||||
"session_id": sid,
|
||||
"mode": "img2img" if ref_url else "txt2img",
|
||||
}
|
||||
if ref_url:
|
||||
extra["upload_image_url"] = ref_url
|
||||
record = import_image_from_url(
|
||||
image_url=image_url,
|
||||
prompt=prompt,
|
||||
extra={"source": "ai-api", "session_id": sid},
|
||||
extra=extra,
|
||||
base_dir=base_dir,
|
||||
)
|
||||
if cancel is not None and cancel.is_set():
|
||||
@@ -593,6 +760,38 @@ def import_image_from_url_async(
|
||||
return t
|
||||
|
||||
|
||||
def upload_image_async(
|
||||
file_path: str,
|
||||
on_success: Callable[[str], None],
|
||||
on_error: Callable[[Exception], None],
|
||||
cancel_event: Optional[threading.Event] = None,
|
||||
timeout: float = API_UPLOAD_TIMEOUT,
|
||||
auto_resize: bool = True,
|
||||
) -> threading.Thread:
|
||||
"""后台上传本地图片到后端,成功回调返回 ``upload_image_url``。
|
||||
|
||||
参数:
|
||||
auto_resize: True 时自动缩放超大图片
|
||||
"""
|
||||
|
||||
def _worker():
|
||||
try:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
return
|
||||
url = _call_pqtest_upload(file_path, timeout=timeout, auto_resize=auto_resize)
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
return
|
||||
on_success(url)
|
||||
except Exception as exc:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
return
|
||||
on_error(exc)
|
||||
|
||||
t = threading.Thread(target=_worker, daemon=True)
|
||||
t.start()
|
||||
return t
|
||||
|
||||
|
||||
def is_remote_image_url(value: str) -> bool:
|
||||
"""判断输入是否为 http/https 图片地址。"""
|
||||
url = (value or "").strip()
|
||||
|
||||
Reference in New Issue
Block a user