feat: 断点续传与色情识别提速/修复,支持 Linux 交叉编译

- feat: 新增续传表 scan_log,重扫跳过已归类文件(尺寸/头尾指纹校验)
- fix: 色情识别记录批量入库超 SQLite 999 变量上限,改每 100 条一批
- perf: NudeNet 检测改为多线程推理池(每线程独立模型实例)
- perf: 明暗分析 JPEG(YCbCr) 快路径;SQLite 读并发放开;加阶段耗时统计
- fix: 前端色情识别状态由后端如实上报(配置开关+检测服务是否起来)
- feat: ffprobe 内嵌按平台拆分,Linux 构建回退系统 ffprobe,支持交叉编译
- docs: README 补充断点续传与 CentOS 交叉编译步骤
- chore: organize.workers 默认 8
This commit is contained in:
2025-06-19 21:07:35 +08:00
parent ea134afa27
commit 957dfe1134
18 changed files with 908 additions and 111 deletions
+81 -21
View File
@@ -1,8 +1,10 @@
# -*- coding: utf-8 -*-
"""NudeNet 本地检测服务:由 Go 侧启动,常驻提供 /health 和 /detect。
只依赖 Python 标准库 + nudenet 包,模型在进程里只加载一次。
收到 /detect 就把图片喂给 NudeDetector,返回结果 JSON。
只依赖 Python 标准库 + nudenet 包。
onnxruntime 的 InferenceSession 不是线程安全的,同一个实例并发推理会崩,
所以这里用一个固定大小的推理线程池:每个 worker 线程各持一份独立模型实例,
N 个请求可以真·并行推理。收到 /detect 就把图片喂给 NudeDetector,返回结果 JSON。
模型加载优先级(模型放在 <程序运行目录>/lib/320n.onnx):
1. 本地已有该文件 → 直接加载(离线可用,适合随程序分发);
@@ -15,12 +17,15 @@ Windows 超长路径说明:OpenCV 的 imread 走老式 C API,路径超过 26
再把字节直接交给 NudeNet(v3 的 detect 支持 bytes)——从内存解码,彻底
绕开路径限制,同时兼容老版 v2(只收路径时落到临时短路径文件再试)。
"""
import concurrent.futures
import json
import os
import shutil
import sys
import tempfile
import threading
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from nudenet import NudeDetector
@@ -49,6 +54,17 @@ MODEL_DIR = sys.argv[2] if len(sys.argv) > 2 else ""
_detector = None
# 推理并发数:onnxruntime 的 InferenceSession.run() 不是线程安全的,
# 同一个实例并发推理会崩,所以每个 worker 线程各持有一个独立实例(NudeDetector),
# N 个 worker 即可真·并行推理。默认 4,可用环境变量 KM_NSFW_WORKERS 调整。
INFER_WORKERS = int(os.environ.get("KM_NSFW_WORKERS", "4") or "4")
if INFER_WORKERS < 1:
INFER_WORKERS = 1
_executor = None # 推理线程池(固定 N 个 worker,模型各持一份)
_thread_local = threading.local() # 每个 worker 线程自己的检测器
_MODEL_PATH = None # 启动时解析好的模型路径(None 表示用包自带模型)
def _looks_like_model(path):
"""粗略校验文件是不是真 ONNX 模型:开头不是 HTML、大小不小于 1MB(320n 模型约 11.6MB)。
@@ -65,6 +81,13 @@ def _looks_like_model(path):
return False
def _download_model(local):
"""带超时地把模型下载到 local。urlretrieve 没有超时,网络挂起会卡死整个服务。"""
req = urllib.request.Request(MODEL_URL)
with urllib.request.urlopen(req, timeout=30) as resp, open(local, "wb") as f:
shutil.copyfileobj(resp, f)
def _ensure_model(model_dir):
"""返回要加载的模型路径;返回 None 表示用 nudenet 包自带的模型。"""
if not model_dir:
@@ -73,11 +96,11 @@ def _ensure_model(model_dir):
if os.path.isfile(local) and _looks_like_model(local):
print("=== 使用本地模型: %s" % local, flush=True)
return local
# 本地没有(或文件无效)→ 下载到 lib 目录(仅第一次需要网络)
# 本地没有(或文件无效)→ 下载到 lib 目录(仅第一次需要网络,带 30 秒超时)
try:
os.makedirs(model_dir, exist_ok=True)
print("=== 本地未找到有效模型, 正在下载: %s" % local, flush=True)
urllib.request.urlretrieve(MODEL_URL, local)
_download_model(local)
if _looks_like_model(local):
print("=== 模型下载完成: %s" % local, flush=True)
return local
@@ -93,22 +116,33 @@ def _ensure_model(model_dir):
def get_detector():
global _detector
"""确保模型已就绪(下载/加载),返回一个检测器实例。
启动时调用一次:把模型下载/解析好,/health 就绪即代表模型可用。
真正推理用的独立实例由 _new_detector() 按线程各建一份,共享这份模型路径。
"""
global _detector, _MODEL_PATH
if _detector is None:
mp = _ensure_model(MODEL_DIR)
try:
_detector = NudeDetector(model_path=mp)
except Exception as exc: # noqa: BLE001 - 指定路径加载失败就回退包自带模型,别崩
print("=== 本地模型加载失败(%s), 回退用包自带模型" % exc, flush=True)
if mp:
try:
os.remove(mp)
except OSError:
pass
_detector = NudeDetector()
_MODEL_PATH = _ensure_model(MODEL_DIR)
_detector = _new_detector()
return _detector
def _new_detector():
"""创建一份独立检测器实例(每个推理 worker 各持一份,可并行推理)。"""
mp = _MODEL_PATH
try:
return NudeDetector(model_path=mp) if mp else NudeDetector()
except Exception as exc: # noqa: BLE001 - 指定路径加载失败就回退包自带模型,别崩
print("=== 本地模型加载失败(%s), 回退用包自带模型" % exc, flush=True)
if mp:
try:
os.remove(mp)
except OSError:
pass
return NudeDetector()
def _longpath(path):
"""Windows 下给绝对路径加 \\\\?\\ 前缀,让 Python 原生 open 也能处理超长路径。"""
if os.name != "nt":
@@ -136,7 +170,10 @@ def _read_bytes(path):
def _detect_bytes(det, data, path):
"""把图片字节喂给 NudeNet。v3 直接吃 bytes;老版 v2 只收路径时落到临时短路径重试。"""
"""把图片字节喂给 NudeNet。v3 直接吃 bytes;老版 v2 只收路径时落到临时短路径重试。
用线程自己的检测器实例推理,各实例互不干扰,可以真·并行。
"""
try:
return det.detect(data)
except (TypeError, AttributeError):
@@ -154,6 +191,19 @@ def _detect_bytes(det, data, path):
pass
def _infer(path):
"""整个推理任务(读文件 + 检测),提交到固定线程池执行。
读文件在池外并行进行;推理用线程各自的独立检测器,N 个 worker 真·并行。
"""
data = _read_bytes(path)
det = getattr(_thread_local, "detector", None)
if det is None:
det = _new_detector()
_thread_local.detector = det
return _detect_bytes(det, data, path)
class Handler(BaseHTTPRequestHandler):
def _send(self, obj, status=200):
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
@@ -179,8 +229,8 @@ class Handler(BaseHTTPRequestHandler):
threshold = float(body.get("threshold", 0.5))
if not path:
return self._send({"ok": False, "error": "缺少图片路径"})
data = _read_bytes(path)
detections = _detect_bytes(get_detector(), data, path)
# 丢给推理线程池:读文件并行、推理由 N 个 worker 并行处理
detections = _executor.submit(_infer, path).result()
except Exception as exc: # noqa: BLE001 - 服务端任何异常都要回给 Go,别让进程崩
return self._send({"ok": False, "error": str(exc)})
@@ -200,9 +250,19 @@ class Handler(BaseHTTPRequestHandler):
pass
# 多线程接收连接 + 大监听队列:Go 侧会并发发请求(worker 数可到 8),
# 单线程 HTTPServer 默认 backlog 只有 5,并发一高连接就被内核拒绝(报 10061 连接拒绝)。
# ThreadingHTTPServer 让连接即时被 accept;推理再交给固定线程池并行执行
# (每个 worker 一份独立模型实例,onnxruntime 同一实例并发推理会崩)。
class Server(ThreadingHTTPServer):
request_queue_size = 128
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 0
# 启动前先把模型准备好(本地加载 / 下载 / 回退),/health 就绪即代表模型可用,
# 这样 Go 侧 waitReady 等到的就是"模型已就绪"的服务。
get_detector()
HTTPServer(("127.0.0.1", port), Handler).serve_forever()
# 固定大小推理线程池:每个 worker 线程持一份独立模型实例,可并行推理
_executor = concurrent.futures.ThreadPoolExecutor(max_workers=INFER_WORKERS)
Server(("127.0.0.1", port), Handler).serve_forever()