-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathmodel_manager.py
More file actions
425 lines (361 loc) · 15.4 KB
/
Copy pathmodel_manager.py
File metadata and controls
425 lines (361 loc) · 15.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
# 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