fix(watchlist): 采纳 #94 审查 — 防大图 OOM、OCR 不阻塞事件循环、收紧图片类型

为截图预处理增加像素上限与长边降采样;import-image 将 OCR 放入线程池;
去掉任意 image/* 放行,仅允许白名单 MIME/扩展名。
This commit is contained in:
ChenJunheng
2026-07-15 23:28:30 +08:00
parent 6f084e1b29
commit 0d6b341945
3 changed files with 61 additions and 9 deletions
+11 -3
View File
@@ -81,12 +81,15 @@ def ocr_status():
@router.post("/import-image")
async def import_from_image(request: Request, file: UploadFile = File(...)):
"""从自选截图识别股票代码,返回候选列表(不自动写入自选)。"""
import anyio
content_type = (file.content_type or "").split(";")[0].strip().lower()
filename = (file.filename or "").lower()
ok_type = content_type in _IMPORT_IMAGE_TYPES or content_type.startswith("image/")
# 严格白名单:不接受任意 image/*(如 image/svg+xml
ok_type = content_type in _IMPORT_IMAGE_TYPES
ok_ext = filename.endswith((".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"))
if not ok_type and not ok_ext:
raise HTTPException(400, "仅支持 JPG / PNG / WebP 图片")
raise HTTPException(400, "仅支持 JPG / PNG / WebP / BMP / GIF 图片")
data = await file.read()
if not data:
@@ -97,7 +100,12 @@ async def import_from_image(request: Request, file: UploadFile = File(...)):
existing = {r["symbol"] for r in watchlist.list_symbols()}
data_dir = request.app.state.repo.store.data_dir
try:
result = import_watchlist_image(data, data_dir, existing_symbols=existing)
# OCR 为同步 CPU/子进程;丢进线程池,避免卡住事件循环(行情 SSE 等)
result = await anyio.to_thread.run_sync(
lambda: import_watchlist_image(data, data_dir, existing_symbols=existing),
)
except ValueError as e:
raise HTTPException(400, str(e)) from e
except RuntimeError as e:
raise HTTPException(503, str(e)) from e
except Exception as e: # noqa: BLE001
+28 -5
View File
@@ -10,6 +10,13 @@ from PIL import Image, ImageEnhance, ImageOps
logger = logging.getLogger(__name__)
# 像素上限:防 JPEG 高分辨率解码后 OOM(字节上限挡不住高压缩大图)
_MAX_PIXELS = 12_000_000
# 长边上限:过大则降采样,减内存并加快 OCR
_MAX_EDGE = 2000
# 过窄时适度放大,提升小字识别率
_MIN_WIDTH = 1400
class OcrProvider(ABC):
"""OCR 引擎接口。实现类只负责「图 → 文本」,代码抽取与证券匹配在 pipeline 中完成。"""
@@ -26,19 +33,35 @@ class OcrProvider(ABC):
def preprocess_for_ocr(image_bytes: bytes) -> Image.Image:
"""暗色券商截图预处理:灰度、反相、增强对比,提升数字识别率。"""
"""暗色券商截图预处理:像素上限、降采样、灰度、反相、增强对比
Raises:
ValueError: 图片分辨率过高(像素数超限)。
"""
img = Image.open(BytesIO(image_bytes))
img.load() # 强制解码,便于尽早失败 / 量尺寸
pixels = img.width * img.height
if pixels > _MAX_PIXELS:
raise ValueError("图片分辨率过高,请裁剪后重试")
# 大图降采样:既减内存又提速 OCR(只放大不缩小的旧逻辑已去掉)
if max(img.size) > _MAX_EDGE:
img.thumbnail((_MAX_EDGE, _MAX_EDGE), Image.Resampling.LANCZOS)
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
gray = ImageOps.grayscale(img)
# 暗底白字 → 白底黑字,Tesseract 更稳
inverted = ImageOps.invert(gray)
contrasted = ImageEnhance.Contrast(inverted).enhance(1.8)
# 小字放大一档
# 小字适度放大
w, h = contrasted.size
if w < 1400:
scale = 1400 / w
contrasted = contrasted.resize((int(w * scale), int(h * scale)), Image.Resampling.LANCZOS)
if w < _MIN_WIDTH:
scale = _MIN_WIDTH / w
contrasted = contrasted.resize(
(int(w * scale), int(h * scale)), Image.Resampling.LANCZOS
)
return contrasted
+22 -1
View File
@@ -1,16 +1,19 @@
"""自选截图 OCR:代码抽取与 instruments 匹配(不依赖本机 tesseract)。"""
from __future__ import annotations
from io import BytesIO
from pathlib import Path
import polars as pl
import pytest
from PIL import Image
from app.services.watchlist_ocr.pipeline import (
extract_codes,
import_watchlist_image,
resolve_candidates,
)
from app.services.watchlist_ocr.provider import OcrProvider
from app.services.watchlist_ocr.provider import OcrProvider, preprocess_for_ocr
class _FakeOcr(OcrProvider):
@@ -26,6 +29,12 @@ class _FakeOcr(OcrProvider):
return self._text
def _png_bytes(width: int, height: int) -> bytes:
buf = BytesIO()
Image.new("RGB", (width, height), color=(20, 20, 20)).save(buf, format="PNG")
return buf.getvalue()
def test_extract_codes_order_and_dedupe():
text = "563230 融\n515880\n价格 1.340\n563230 重复\nXAUUSD\n601636"
assert extract_codes(text) == ["563230", "515880", "601636"]
@@ -73,3 +82,15 @@ def test_import_watchlist_image_with_fake_ocr(tmp_path: Path):
assert result["codes"] == ["600036", "515880"]
assert result["matched_count"] == 2
assert result["unmatched_count"] == 0
def test_preprocess_rejects_excessive_pixels():
# 4000×8000 = 32M 像素 > 12M 上限
with pytest.raises(ValueError, match="分辨率过高"):
preprocess_for_ocr(_png_bytes(4000, 8000))
def test_preprocess_downsamples_large_edge():
# 2500×1000 = 2.5M 像素未超限,但长边 > 2000,应降采样
out = preprocess_for_ocr(_png_bytes(2500, 1000))
assert max(out.size) <= 2000