mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
fix(watchlist): 采纳 #94 审查 — 防大图 OOM、OCR 不阻塞事件循环、收紧图片类型
为截图预处理增加像素上限与长边降采样;import-image 将 OCR 放入线程池; 去掉任意 image/* 放行,仅允许白名单 MIME/扩展名。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user