Files
Geo1230 ca8e48fb7a Initial commit
ComfyUI-DeepseekOCR plugin v1.0.0
2025-11-10 15:18:14 +08:00

426 lines
15 KiB
Python
Raw Permalink 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.
# model_manager.py
from pathlib import Path
from typing import Optional, Dict
from dataclasses import dataclass
import os, time, json, logging, warnings, subprocess, shutil
from huggingface_hub import HfApi
from transformers import AutoTokenizer, AutoModel
import torch
from .config import (
REPO_ID, MODELS_HOME, HF_CACHE, REVISION, AUTODOWNLOAD, local_snapshot_dir, LoadConfig, LOG_DIR
)
# 抑制 huggingface_hub 的 hf_xet 警告
warnings.filterwarnings("ignore", message=".*hf_xet.*")
warnings.filterwarnings("ignore", message=".*Xet Storage.*")
# 配置日志
LOG_DIR.mkdir(parents=True, exist_ok=True)
HF_CACHE.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='[%(asctime)s] [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(LOG_DIR / "deepseek_ocr.log", encoding='utf-8'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
_LOCK_NAME = ".download.lock"
def _format_bytes(size: int) -> str:
if size <= 0:
return "0B"
units = ["B", "KB", "MB", "GB", "TB", "PB"]
idx = 0
value = float(size)
while value >= 1024 and idx < len(units) - 1:
value /= 1024
idx += 1
if idx == 0:
return f"{int(value)}{units[idx]}"
return f"{value:.2f}{units[idx]}"
def _preferred_workers() -> int:
env_value = os.getenv("DPSK_DOWNLOAD_WORKERS")
if env_value:
try:
workers = int(env_value)
if workers > 0:
return workers
except ValueError:
logger.warning(f"DPSK_DOWNLOAD_WORKERS 非法:{env_value}")
cpu = os.cpu_count() or 4
return max(4, min(32, cpu * 2))
def _repo_snapshot_meta(repo_id: str, revision: Optional[str]):
try:
api = HfApi()
info = api.repo_info(
repo_id=repo_id,
revision=revision,
repo_type="model",
files_metadata=True,
)
total_bytes = sum((s.size or 0) for s in info.siblings if getattr(s, "size", None))
files = len([s for s in info.siblings if getattr(s, "size", None)])
return info, total_bytes, files
except Exception as exc:
logger.warning(f"无法获取仓库元数据:{exc}")
return None, None, None
def _download_with_cli(repo_id: str, local_dir: Path, revision: Optional[str], max_workers: int) -> str:
"""使用 huggingface-cli 下载模型,实时显示原生进度信息"""
cli_path = shutil.which("huggingface-cli")
if not cli_path:
raise RuntimeError(
"未找到 huggingface-cli 命令!\n"
"请安装 huggingface_hub:\n"
" pip install huggingface_hub[cli]\n"
"或:\n"
" pip install -U huggingface_hub"
)
cmd = [
cli_path,
"download",
repo_id,
"--repo-type",
"model",
"--local-dir",
str(local_dir),
"--cache-dir",
str(HF_CACHE),
"--resume-download",
"--max-workers",
str(max_workers),
]
if revision:
cmd.extend(["--revision", revision])
env = os.environ.copy()
env.setdefault("HF_HOME", str(HF_CACHE))
env.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
env.setdefault("PYTHONIOENCODING", "utf-8")
env.setdefault("PYTHONUTF8", "1")
logger.info(f"使用 huggingface-cli 下载 ({max_workers} workers)...")
logger.info("=" * 70)
# 直接继承当前终端,实时显示完整输出
return_code = subprocess.call(cmd, env=env)
if return_code != 0:
raise RuntimeError(f"huggingface-cli 下载失败 (exit code: {return_code})")
# 获取 commit SHA
try:
info = HfApi().repo_info(repo_id=repo_id, revision=revision, repo_type="model")
return info.sha
except Exception:
return revision or "main"
def _is_ready(local_dir: Path) -> bool:
"""
判断快照目录是否已完整:至少要有 config/tokenizer 和任意 .safetensors
"""
if not local_dir.exists():
return False
need = ["config.json", "tokenizer.json"]
has_weights = any(p.suffix == ".safetensors" for p in local_dir.glob("*.safetensors"))
return all((local_dir / n).exists() for n in need) and has_weights
class _FileLock:
"""
简易文件锁:创建独占文件,存在则轮询等待(适合首次下载并发场景)
"""
def __init__(self, lock_path: Path, interval: float = 0.5):
self.lock_path = lock_path
self.interval = interval
self._fd = None
def __enter__(self):
while True:
try:
self._fd = os.open(self.lock_path, os.O_CREAT | os.O_EXCL | os.O_RDWR)
os.write(self._fd, b"1")
return self
except FileExistsError:
time.sleep(self.interval)
def __exit__(self, exc_type, exc, tb):
try:
if self._fd is not None:
os.close(self._fd)
if self.lock_path.exists():
os.remove(self.lock_path)
except Exception:
pass
def _write_manifest(local_dir: Path, commit: Optional[str]):
data = {"repo_id": REPO_ID, "revision": commit, "ts": int(time.time())}
(local_dir / "manifest.json").write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
)
def ensure_weights_local(repo_id: str = REPO_ID) -> Path:
"""
确保 DeepSeek-OCR 权重被下载到:
ComfyUI/models/deepseek-ocr/<org_repo_下划线替换>/
- 未就绪且 AUTODOWNLOAD=0:抛出指引性错误
- 未就绪且 AUTODOWNLOAD=1:自动下载(带文件锁)
返回本地快照目录 Path
"""
local = local_snapshot_dir(repo_id) # e.g. .../models/deepseek-ocr/deepseek-ai_DeepSeek-OCR
local.mkdir(parents=True, exist_ok=True)
if _is_ready(local):
return local
if not AUTODOWNLOAD:
raise RuntimeError(
f"[DeepseekOCR] 权重缺失:{local}\n"
f"自动下载已禁用(DPSK_AUTODOWNLOAD=0)。\n"
f"解决方案:\n"
f"1. 删除环境变量 DPSK_AUTODOWNLOAD(启用自动下载)\n"
f"2. 或手动运行 tools\\download_weights.py 预下载"
)
lock = MODELS_HOME / _LOCK_NAME
logger.info(f"准备下载权重到: {local}")
with _FileLock(lock):
# 可能别的进程已经下好了,再检查一次
if _is_ready(local):
logger.info("其他进程已完成下载")
return local
logger.info(f"开始从 HuggingFace 下载: {repo_id}")
logger.info(f"目标目录: {local}")
logger.info("下载模型权重(约 8-10GB,首次下载需要时间,请耐心等待)")
try:
# 获取仓库元数据(可选)
info, total_bytes, total_files = _repo_snapshot_meta(repo_id, REVISION)
if total_files:
logger.info(f"仓库共有 {total_files} 个文件待下载/校验")
if total_bytes:
logger.info(f"预估总下载量:{_format_bytes(total_bytes)}")
max_workers = _preferred_workers()
resolved_revision = REVISION or (info.sha if info else None)
# 使用 huggingface-cli 下载
commit = _download_with_cli(repo_id, local, resolved_revision, max_workers)
# 写入清单文件
_write_manifest(local, commit)
logger.info("=" * 70)
logger.info(f"✓ 下载完成!commit: {commit}")
return local
except Exception as e:
logger.error(f"✗ 下载失败: {e}")
raise RuntimeError(
f"权重下载失败!\n"
f"建议:\n"
f"1. 检查网络连接或设置 HF_ENDPOINT 镜像\n"
f"2. 设置 HF_TOKEN(如果仓库需要认证)\n"
f"3. 确保已安装 huggingface_hub[cli]\n"
f"4. 手动下载到 {local}\n"
f"原始错误: {e}"
) from e
# ========== OCRHandle & 模型加载 ==========
@dataclass
class OCRHandle:
"""模型句柄:包含 tokenizer、model、config"""
tokenizer: any
model: any
cfg: LoadConfig
def infer_one(self, image_path: str, prompt: str, preset: dict, save_results: bool, output_path: str = None):
"""
便捷推理接口,内部转发到 model.infer(...)
preset: resolver.map_resolution() 返回的字典
策略:使用 eval_mode=True 直接获取文本返回值
"""
# 使用 eval_mode=True 获取文本返回值
result = self.model.infer(
tokenizer=self.tokenizer,
prompt=prompt,
image_file=image_path,
output_path=output_path or ".",
base_size=preset.get("base_size", 1024),
image_size=preset.get("image_size", 640),
crop_mode=preset.get("crop_mode", True),
save_results=False, # 不生成文件,直接返回
test_compress=False, # 不打印压缩率
eval_mode=True, # 启用 eval_mode,返回文本
)
return result # 返回文本
@property
def device(self) -> str:
return self.cfg.device
@property
def dtype(self) -> str:
return self.cfg.dtype
@property
def attn_impl(self) -> str:
return self.cfg.attn_impl
# 全局缓存:key=LoadConfig, value=OCRHandle
_GLOBAL_HANDLES: Dict[LoadConfig, OCRHandle] = {}
def load_model(cfg: LoadConfig) -> OCRHandle:
"""
加载模型,支持缓存与降级策略:
- attn_impl: 优先 sdpa,flash_attention_2 失败时回退
- dtype: bf16 → fp16 → fp32 逐级降级
返回 OCRHandle
"""
# 检查缓存
if cfg in _GLOBAL_HANDLES:
logger.info(f"[DeepseekOCR] 命中缓存:{cfg.local_dir} | {cfg.device} | {cfg.dtype} | {cfg.attn_impl}")
return _GLOBAL_HANDLES[cfg]
logger.info(f"[DeepseekOCR] 开始加载模型:{cfg.local_dir}")
logger.info(f" device={cfg.device}, dtype={cfg.dtype}")
# 注意:DeepSeek-OCR 模型内部会自动选择 attention 实现
# 如果安装了 flash-attn,会自动使用;否则使用 eager attention
# 1. 加载 tokenizer
try:
tokenizer = AutoTokenizer.from_pretrained(
cfg.local_dir,
trust_remote_code=True
)
logger.info("[DeepseekOCR] tokenizer 加载成功")
except Exception as e:
logger.error(f"[DeepseekOCR] tokenizer 加载失败: {e}")
raise RuntimeError(f"Tokenizer 加载失败: {e}") from e
# 3. 加载 model,带 dtype 降级
dtype_list = []
if cfg.dtype == "bf16":
dtype_list = [torch.bfloat16, torch.float16, torch.float32]
elif cfg.dtype == "fp16":
dtype_list = [torch.float16, torch.float32]
else:
dtype_list = [torch.float32]
# 环境检查
cuda_available = torch.cuda.is_available()
if cfg.device.startswith("cuda") and not cuda_available:
raise RuntimeError(
"[DeepseekOCR] CUDA 不可用!\n"
f"请求设备: {cfg.device}\n"
f"CUDA 可用: {cuda_available}\n"
"请检查:\n"
"1. 是否安装了正确的 PyTorch CUDA 版本\n"
"2. 显卡驱动是否正常\n"
"3. 或改用 device='cpu'(速度较慢)"
)
if cuda_available and cfg.device.startswith("cuda"):
device_id = 0 if cfg.device == "cuda" else int(cfg.device.split(":")[-1])
try:
props = torch.cuda.get_device_properties(device_id)
total_mem = props.total_memory / (1024**3) # GB
logger.info(f"[DeepseekOCR] GPU: {props.name}, 显存: {total_mem:.2f} GB")
except Exception:
pass
model = None
final_dtype = None
last_error = None
for dtype in dtype_list:
try:
logger.info(f"[DeepseekOCR] 尝试加载模型 dtype={dtype}")
# DeepSeek-OCR 不支持通过 _attn_implementation 参数指定 attention
# 模型会使用默认的 eager attention 实现
# 如果需要 flash_attention_2,需要安装 flash-attn 后模型会自动检测使用
model = AutoModel.from_pretrained(
cfg.local_dir,
trust_remote_code=True,
torch_dtype=dtype,
use_safetensors=True,
)
logger.info(f"[DeepseekOCR] 模型使用默认 attention 实现(eager)")
model.eval()
logger.info(f"[DeepseekOCR] 模型已加载,正在移动到 {cfg.device}...")
model.to(cfg.device)
final_dtype = dtype
logger.info(f"[DeepseekOCR] ✓ 模型加载成功:dtype={dtype}, device={cfg.device}")
break
except Exception as e:
last_error = e
error_str = str(e)
logger.error(f"[DeepseekOCR] ✗ dtype={dtype} 加载失败")
logger.error(f" 错误类型: {type(e).__name__}")
logger.error(f" 错误信息: {error_str[:200]}")
# 检查是否是显存不足
if "out of memory" in error_str.lower() or "oom" in error_str.lower():
logger.error(" >> 诊断: 显存不足(OOM)")
elif "cuda" in error_str.lower() and "driver" in error_str.lower():
logger.error(" >> 诊断: CUDA 驱动问题")
elif "trust_remote_code" in error_str.lower():
logger.error(" >> 诊断: trust_remote_code 相关问题,检查模型文件完整性")
# 如果是最后一个 dtype,不 continue
if dtype == dtype_list[-1]:
break
else:
logger.warning(f" 尝试降级到下一个 dtype...")
continue
if model is None:
# 构建详细错误信息
error_details = f"\n最后一次错误: {type(last_error).__name__}: {str(last_error)[:300]}" if last_error else ""
raise RuntimeError(
"[DeepseekOCR] 模型加载失败!所有 dtype 尝试均失败。\n"
f"尝试的 dtype: {[str(d) for d in dtype_list]}\n"
f"{error_details}\n\n"
"建议排查:\n"
"1. 显存不足:\n"
" - 关闭其他占用显存的程序\n"
" - 使用更小的 dtype (fp16)\n"
" - 或在 Run 节点选择 Tiny/Small 分辨率\n"
"2. 模型文件损坏:\n"
f" - 删除 {cfg.local_dir}\n"
" - 重新下载模型\n"
"3. PyTorch/CUDA 问题:\n"
" - 检查 torch.cuda.is_available()\n"
" - 更新显卡驱动\n"
f"4. 查看完整日志: {LOG_DIR / 'deepseek_ocr.log'}"
)
# 4. 创建 Handle 并缓存
handle = OCRHandle(tokenizer=tokenizer, model=model, cfg=cfg)
_GLOBAL_HANDLES[cfg] = handle
logger.info(f"[DeepseekOCR] 模型已缓存,最终配置:dtype={final_dtype}")
return handle