Merge pull request #5144 from myhloli/dev

Refactor generation logic and enhance model configuration handling
This commit is contained in:
Xiaomeng Zhao
2026-06-18 11:04:28 +08:00
committed by GitHub
9 changed files with 441 additions and 137 deletions
+14 -5
View File
@@ -2,13 +2,14 @@
MinerU uses `HuggingFace` and `ModelScope` as model repositories. Users can switch model sources or use local models as needed.
- `HuggingFace` is the default model source, providing excellent loading speed and high stability globally.
- `auto` is the default model source policy. It first requests `https://huggingface.co/models` to check whether Hugging Face is reachable. If reachable, MinerU uses `HuggingFace`; otherwise, it falls back to `ModelScope`.
- `HuggingFace` provides excellent loading speed and high stability globally.
- `ModelScope` is the best choice for users in mainland China, providing seamlessly compatible `hf` SDK modules, suitable for users who cannot access HuggingFace.
## Methods to Switch Model Sources
### Configure via Environment Variables
MinerU configures model sources through the `MINERU_MODEL_SOURCE` environment variable. This applies to all command line tools and API calls.
MinerU configures model sources through the `MINERU_MODEL_SOURCE` environment variable. This applies to all command line tools and API calls. Supported values are `huggingface`, `modelscope`, and `local`. The environment variable has higher priority than `model-source` in `mineru.json`. Do not set this environment variable to `auto`; unset it if you want MinerU to choose a source automatically.
```bash
export MINERU_MODEL_SOURCE=modelscope
mineru -p <input_path> -o <output_path>
@@ -21,6 +22,14 @@ os.environ["MINERU_MODEL_SOURCE"] = "modelscope"
>[!TIP]
> MinerU no longer provides a CLI flag for model source selection. Model sources set through environment variables take effect in the current terminal session until the terminal is closed or the environment variable is modified.
### Configure via Configuration File
If `MINERU_MODEL_SOURCE` is not set, MinerU reads the `model-source` field from `mineru.json` in the user directory. `model-source` supports fixed values `huggingface` and `modelscope`, and also supports the template's first-run placeholder value `auto`. When the value is `auto` or the field is missing, MinerU probes the actual source first. After the first auto probe resolves an actual source, MinerU writes `model-source` back as `huggingface` or `modelscope` to avoid switching sources on later startups due to network fluctuations.
```json
{
"model-source": "auto"
}
```
## Using Local Models
### 1. Download Models to Local Storage
@@ -32,12 +41,12 @@ or use the interactive command line tool to select model downloads:
mineru-models-download
```
> [!NOTE]
>- After download completion, the model path will be output in the current terminal window and automatically written to `mineru.json` in the user directory.
>- You can also create it by copying the [configuration template file](https://github.com/opendatalab/MinerU/blob/master/mineru.template.json) to your user directory and renaming it to `mineru.json`.
>- After download completion, the model path will be output in the current terminal window and automatically written to `mineru.json` in the user directory. The `model-source` field records the actual remote source used for this download, either `huggingface` or `modelscope`.
>- You can also create it by copying the [configuration template file](https://github.com/opendatalab/MinerU/blob/master/mineru.template.json) to your user directory and renaming it to `mineru.json`. The template sets `model-source` to `auto`, so MinerU auto-detects once and writes back the resolved source on first use.
>- After downloading models locally, you can freely move the model folder to other locations while updating the model path in `mineru.json`.
>- If you deploy the model folder to another server, please ensure you move the `mineru.json` file to the user directory of the new device and configure the model path correctly.
>- If you need to update model files, you can run the `mineru-models-download` command again. Model updates do not support custom paths currently - if you haven't moved the local model folder, model files will be incrementally updated; if you have moved the model folder, model files will be re-downloaded to the default location and `mineru.json` will be updated.
>- `mineru-models-download` must use a remote model source to perform a real download. If your current shell already sets `MINERU_MODEL_SOURCE=local`, this command will temporarily ignore that value for this invocation and use your selected `huggingface` or `modelscope` source instead.
>- `mineru-models-download` must use a remote model source to perform a real download. If your current shell already sets `MINERU_MODEL_SOURCE=local`, this command will temporarily ignore that value for this invocation and use your selected `auto`, `huggingface`, or `modelscope` source instead.
### 2. Use Local Models for Parsing
+14 -5
View File
@@ -2,13 +2,14 @@
MinerU使用 `HuggingFace``ModelScope` 作为模型仓库,用户可以根据需要切换模型源或使用本地模型。
- `HuggingFace` 是默认的模型源,在全球范围内提供了优异的加载速度和极高稳定性
- `auto` 是默认的模型源策略,会先请求 `https://huggingface.co/models` 探测 HuggingFace 是否可访问;可访问时使用 `HuggingFace`,不可访问时自动回退到 `ModelScope`
- `HuggingFace` 在全球范围内提供了优异的加载速度和极高稳定性。
- `ModelScope` 是中国大陆地区用户的最佳选择,提供了无缝兼容的SDK模块,适用于无法访问`HuggingFace`的用户。
## 模型源的切换方法
### 通过环境变量切换
MinerU 通过 `MINERU_MODEL_SOURCE` 环境变量配置模型源,这适用于所有命令行工具和 API 调用。
MinerU 通过 `MINERU_MODEL_SOURCE` 环境变量配置模型源,这适用于所有命令行工具和 API 调用。支持的取值为 `huggingface``modelscope``local`,环境变量优先级高于 `mineru.json` 中的 `model-source`。请不要将环境变量设置为 `auto`;如需自动选择来源,请删除该环境变量。
```bash
export MINERU_MODEL_SOURCE=modelscope
mineru -p <input_path> -o <output_path>
@@ -21,6 +22,14 @@ os.environ["MINERU_MODEL_SOURCE"] = "modelscope"
>[!TIP]
> MinerU 已不再提供用于切换模型源的命令行参数。通过环境变量设置的模型源会在当前终端会话中生效,直到终端关闭或环境变量被修改。
### 通过配置文件切换
如果未设置 `MINERU_MODEL_SOURCE`MinerU 会读取用户目录下 `mineru.json` 中的 `model-source` 字段。`model-source` 支持固定值 `huggingface``modelscope`,也支持模板中的首次解析占位值 `auto`。当值为 `auto` 或字段缺失时,会先自动探测实际来源;首次自动探测完成后,会将 `model-source` 写回为 `huggingface``modelscope`,避免后续启动时因网络波动反复切换来源。
```json
{
"model-source": "auto"
}
```
## 使用本地模型
@@ -33,12 +42,12 @@ mineru-models-download --help
mineru-models-download
```
> [!NOTE]
>- 下载完成后,模型路径会在当前终端窗口输出,并自动写入用户目录下的 `mineru.json`
>- 您也可以通过将[配置模板文件](https://github.com/opendatalab/MinerU/blob/master/mineru.template.json)复制到用户目录下并重命名为 `mineru.json` 来创建配置文件。
>- 下载完成后,模型路径会在当前终端窗口输出,并自动写入用户目录下的 `mineru.json`配置文件中的 `model-source` 会记录本次实际使用的远端来源,即 `huggingface``modelscope`
>- 您也可以通过将[配置模板文件](https://github.com/opendatalab/MinerU/blob/master/mineru.template.json)复制到用户目录下并重命名为 `mineru.json` 来创建配置文件;模板中的 `model-source` 默认为 `auto`,首次使用时会自动探测并写回实际来源
>- 模型下载到本地后,您可以自由移动模型文件夹到其他位置,同时需要在 `mineru.json` 中更新模型路径。
>- 如您将模型文件夹部署到其他服务器上,请确保将 `mineru.json`文件一同移动到新设备的用户目录中并正确配置模型路径。
>- 如您需要更新模型文件,可以再次运行 `mineru-models-download` 命令,模型更新暂不支持自定义路径,如您没有移动本地模型文件夹,模型文件会增量更新;如您移动了模型文件夹,模型文件会重新下载到默认位置并更新`mineru.json`
>- `mineru-models-download` 必须使用远端模型源执行真实下载;如果当前终端已设置 `MINERU_MODEL_SOURCE=local`,该命令会仅在本次执行中临时忽略该值,并改用您选择的 `huggingface``modelscope` 下载模型。
>- `mineru-models-download` 必须使用远端模型源执行真实下载;如果当前终端已设置 `MINERU_MODEL_SOURCE=local`,该命令会仅在本次执行中临时忽略该值,并改用您选择的 `auto``huggingface``modelscope` 下载模型。
### 2. 使用本地模型进行解析
+3 -2
View File
@@ -26,5 +26,6 @@
"pipeline": "",
"vlm": ""
},
"config_version": "1.3.1"
}
"model-source": "auto",
"config_version": "1.3.2"
}
+12 -25
View File
@@ -17,6 +17,7 @@ from ...model.table.rec.unet_table.main import UnetTableModel
from ...utils.config_reader import get_device
from ...utils.enum_class import ModelPath
from ...utils.models_download_utils import auto_download_and_get_model_root_path
from ...utils.ocr_language import normalize_ocr_model_lang
PIPELINE_MODEL_INIT_LOCK = threading.RLock()
# 这些锁保护 pipeline 与 hybrid 共享的 atom model/native 模型推理调用,避免多线程同时进入同一个模型对象。
@@ -128,34 +129,20 @@ def pp_doclayout_v2_model_init(weight, device='cpu'):
model = PPDocLayoutV2LayoutModel(weight, device)
return model
def ocr_model_init(det_db_box_thresh=0.5,
lang=None,
det_db_unclip_ratio=1.5,
enable_merge_det_boxes=True
):
ocr_kwargs = {
"lang": normalize_ocr_model_lang(lang),
"det_db_box_thresh": det_db_box_thresh,
"det_db_unclip_ratio": det_db_unclip_ratio,
"enable_merge_det_boxes": enable_merge_det_boxes,
}
if lang in [None, "ch"]:
use_dilation = True
det_db_unclip_ratio = 1.8
else:
use_dilation = False
if lang is not None and lang != '':
model = PytorchPaddleOCR(
det_db_box_thresh=det_db_box_thresh,
lang=lang,
use_dilation=use_dilation,
det_db_unclip_ratio=det_db_unclip_ratio,
enable_merge_det_boxes=enable_merge_det_boxes,
)
else:
model = PytorchPaddleOCR(
det_db_box_thresh=det_db_box_thresh,
use_dilation=use_dilation,
det_db_unclip_ratio=det_db_unclip_ratio,
enable_merge_det_boxes=enable_merge_det_boxes,
)
return model
return PytorchPaddleOCR(**ocr_kwargs)
class AtomModelSingleton:
@@ -172,17 +159,17 @@ class AtomModelSingleton:
def get_atom_model(self, atom_model_name: str, **kwargs):
lang = kwargs.get('lang', None)
ocr_singleton_lang = normalize_ocr_model_lang(lang)
if atom_model_name in [AtomicModel.WiredTable, AtomicModel.WirelessTable]:
key = (
atom_model_name,
lang
ocr_singleton_lang
)
elif atom_model_name in [AtomicModel.OCR]:
key = (
atom_model_name,
kwargs.get('det_db_box_thresh', 0.5),
lang,
ocr_singleton_lang,
kwargs.get('det_db_unclip_ratio', 1.5),
kwargs.get('enable_merge_det_boxes', True)
)
+25 -55
View File
@@ -1,69 +1,39 @@
# Copyright (c) Opendatalab. All rights reserved.
from contextlib import contextmanager
import json
import os
import sys
import click
import requests
from loguru import logger
from mineru.utils.enum_class import ModelPath
from mineru.utils.models_download_utils import auto_download_and_get_model_root_path
from mineru.utils.models_download_utils import (
CONFIG_TEMPLATE_URL,
auto_download_and_get_model_root_path,
download_and_modify_json,
get_tools_config_file_path,
resolve_model_source,
)
MODEL_SOURCE_ENV_VAR = 'MINERU_MODEL_SOURCE'
REMOTE_MODEL_SOURCES = ('huggingface', 'modelscope')
REMOTE_MODEL_SOURCES = ('auto', 'huggingface', 'modelscope')
def download_json(url):
"""下载JSON文件"""
response = requests.get(url)
response.raise_for_status()
return response.json()
def download_and_modify_json(url, local_filename, modifications):
"""下载JSON并修改内容"""
if os.path.exists(local_filename):
data = json.load(open(local_filename))
config_version = data.get('config_version', '0.0.0')
if config_version < '1.3.1':
data = download_json(url)
else:
data = download_json(url)
# 修改内容
for key, value in modifications.items():
if key in data:
if isinstance(data[key], dict):
# 如果是字典,合并新值
data[key].update(value)
else:
# 否则直接替换
data[key] = value
# 保存修改后的内容
with open(local_filename, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=4)
def configure_model(model_dir, model_type):
def configure_model(model_dir, model_type, model_source):
"""配置模型"""
json_url = 'https://gcore.jsdelivr.net/gh/opendatalab/MinerU@master/mineru.template.json'
config_file_name = os.getenv('MINERU_TOOLS_CONFIG_JSON', 'mineru.json')
home_dir = os.path.expanduser('~')
config_file = os.path.join(home_dir, config_file_name)
config_file = get_tools_config_file_path()
json_mods = {
'models-dir': {
f'{model_type}': model_dir
}
},
'model-source': model_source,
}
download_and_modify_json(json_url, config_file, json_mods)
download_and_modify_json(CONFIG_TEMPLATE_URL, config_file, json_mods)
logger.info(f'The configuration file has been successfully configured, the path is: {config_file}')
def download_pipeline_models():
def download_pipeline_models(model_source):
"""下载Pipeline模型"""
model_paths = [
ModelPath.pp_doclayout_v2,
@@ -79,14 +49,14 @@ def download_pipeline_models():
logger.info(f"Downloading model: {model_path}")
download_finish_path = auto_download_and_get_model_root_path(model_path, repo_mode='pipeline')
logger.info(f"Pipeline models downloaded successfully to: {download_finish_path}")
configure_model(download_finish_path, "pipeline")
configure_model(download_finish_path, "pipeline", model_source)
def download_vlm_models():
def download_vlm_models(model_source):
"""下载VLM模型"""
download_finish_path = auto_download_and_get_model_root_path("/", repo_mode='vlm')
logger.info(f"VLM models downloaded successfully to: {download_finish_path}")
configure_model(download_finish_path, "vlm")
configure_model(download_finish_path, "vlm", model_source)
def get_effective_download_model_source(requested_model_source):
@@ -98,12 +68,12 @@ def get_effective_download_model_source(requested_model_source):
f"`mineru-models-download` will temporarily use '{requested_model_source}' "
f"to perform a real download."
)
return requested_model_source
return resolve_model_source(requested_model_source, allow_auto=True)
if current_model_source is None:
return requested_model_source
return resolve_model_source(requested_model_source, allow_auto=True)
return current_model_source
return resolve_model_source(current_model_source)
@contextmanager
@@ -151,7 +121,7 @@ def download_models(model_source, model_type):
model_source = click.prompt(
"Please select the model download source: ",
type=click.Choice(REMOTE_MODEL_SOURCES),
default='huggingface'
default='auto'
)
effective_model_source = get_effective_download_model_source(model_source)
@@ -169,12 +139,12 @@ def download_models(model_source, model_type):
try:
with temporary_model_source(effective_model_source):
if model_type == 'pipeline':
download_pipeline_models()
download_pipeline_models(effective_model_source)
elif model_type == 'vlm':
download_vlm_models()
download_vlm_models(effective_model_source)
elif model_type == 'all':
download_pipeline_models()
download_vlm_models()
download_pipeline_models(effective_model_source)
download_vlm_models(effective_model_source)
else:
click.echo(f"Unsupported model type: {model_type}", err=True)
sys.exit(1)
+29 -8
View File
@@ -40,6 +40,16 @@ class UnimernetModel(object):
self.model = self.model.to(dtype=torch.float16)
self.model.eval()
@staticmethod
def _should_pin_memory(device) -> bool:
"""判断 DataLoader 是否需要启用 pinned memory,仅 CUDA 搬运受益。"""
return str(device).startswith("cuda")
@staticmethod
def _should_non_blocking_transfer(device) -> bool:
"""判断 tensor 搬运是否使用 non_blocking,需与 pinned memory 保持一致。"""
return UnimernetModel._should_pin_memory(device)
@staticmethod
def _normalize_bbox(bbox, image):
if bbox is None:
@@ -149,21 +159,32 @@ class UnimernetModel(object):
batch_groups = build_mfr_batch_groups(sorted_areas, batch_size)
dataset = MathDataset(sorted_images, transform=self.model.transform)
dataloader = DataLoader(dataset, batch_sampler=batch_groups, num_workers=0)
pin_memory = self._should_pin_memory(self.device)
non_blocking = self._should_non_blocking_transfer(self.device)
dataloader = DataLoader(
dataset,
batch_sampler=batch_groups,
num_workers=0,
pin_memory=pin_memory,
)
mfr_res = []
with tqdm(total=len(sorted_images), desc="MFR Predict") as pbar:
for batch_group, mf_img in zip(batch_groups, dataloader):
current_batch_size = len(batch_group)
mf_img = mf_img.to(dtype=self.model.dtype)
mf_img = mf_img.to(self.device)
with torch.no_grad():
with torch.inference_mode():
for batch_group, mf_img in zip(batch_groups, dataloader):
current_batch_size = len(batch_group)
mf_img = mf_img.to(
device=self.device,
dtype=self.model.dtype,
non_blocking=non_blocking,
)
output = self.model.generate(
{"image": mf_img},
batch_size=current_batch_size,
return_full_result=False,
)
mfr_res.extend(output["fixed_str"])
pbar.update(current_batch_size)
mfr_res.extend(output["fixed_str"])
pbar.update(current_batch_size)
unsorted_results = [""] * len(mfr_res)
for new_idx, latex in enumerate(mfr_res):
@@ -144,7 +144,32 @@ class UnimernetModel(VisionEncoderDecoderModel):
).loss
return {"loss": loss}
def generate(self, samples, do_sample: bool = False, temperature: float = 0.2, top_p: float = 0.95, batch_size=64):
def _decode_generate_outputs(self, outputs, return_full_result: bool = True):
"""统一解码生成结果,轻量路径只保留调用方实际消费的公式文本。"""
token_ids = outputs[:, 1:].cpu()
result_tokens = token_ids.numpy() if return_full_result else token_ids
pred_str = self.tokenizer.token2str(result_tokens)
fixed_str = [latex_rm_whitespace(s) for s in pred_str]
if not return_full_result:
return {"fixed_str": fixed_str}
return {
"pred_ids": result_tokens,
"pred_tokens": self.tokenizer.detokenize(result_tokens),
"pred_str": pred_str,
"fixed_str": fixed_str,
}
def generate(
self,
samples,
do_sample: bool = False,
temperature: float = 0.2,
top_p: float = 0.95,
batch_size=64,
return_full_result: bool = True,
):
pixel_values = samples["image"]
num_channels = pixel_values.shape[1]
if num_channels == 1:
@@ -169,9 +194,7 @@ class UnimernetModel(VisionEncoderDecoderModel):
**kwargs,
)
outputs = outputs[:, 1:].cpu().numpy()
pred_tokens = self.tokenizer.detokenize(outputs)
pred_str = self.tokenizer.token2str(outputs)
fixed_str = [latex_rm_whitespace(s) for s in pred_str]
return {"pred_ids": outputs, "pred_tokens": pred_tokens, "pred_str": pred_str, "fixed_str": fixed_str}
return self._decode_generate_outputs(
outputs,
return_full_result=return_full_result,
)
+30
View File
@@ -30,6 +30,36 @@ def read_config():
return config
def get_configured_model_source(default: str | None = None) -> str | None:
"""读取配置文件中的固定模型来源配置,auto 或缺失时返回默认值。"""
supported_sources = {'huggingface', 'modelscope'}
config = read_config()
if config is None:
return default
model_source = config.get('model-source')
if model_source is None:
return default
if not isinstance(model_source, str):
logger.warning(
f"'model-source' in {CONFIG_FILE_NAME} must be a string, use {default} as default"
)
return default
normalized_model_source = model_source.strip().lower()
if not normalized_model_source:
return default
if normalized_model_source == "auto":
return default
if normalized_model_source in supported_sources:
return normalized_model_source
logger.warning(
f"Unsupported 'model-source' in {CONFIG_FILE_NAME}: {model_source}, use {default} as default"
)
return default
def get_s3_config(bucket_name: str):
"""~/magic-pdf.json 读出来."""
config = read_config()
+284 -30
View File
@@ -1,11 +1,281 @@
# Copyright (c) Opendatalab. All rights reserved.
import json
import os
from huggingface_hub import snapshot_download as hf_snapshot_download
from modelscope import snapshot_download as ms_snapshot_download
from functools import lru_cache
from mineru.utils.config_reader import get_local_models_dir
from huggingface_hub import snapshot_download as hf_snapshot_download
from loguru import logger
from modelscope import snapshot_download as ms_snapshot_download
import requests
from mineru.utils.config_reader import get_configured_model_source, get_local_models_dir
from mineru.utils.enum_class import ModelPath
MODEL_SOURCE_ENV_VAR = 'MINERU_MODEL_SOURCE'
CONFIG_TEMPLATE_URL = 'https://gcore.jsdelivr.net/gh/opendatalab/MinerU@master/mineru.template.json'
MINERU_CONFIG_VERSION = '1.3.2'
HUGGINGFACE_MODELS_PAGE_URL = "https://huggingface.co/models"
HUGGINGFACE_MODELS_PAGE_TIMEOUT = 3
HUGGINGFACE_MODELS_PAGE_MAX_ATTEMPTS = 2
REMOTE_MODEL_SOURCES = ("huggingface", "modelscope")
def get_tools_config_file_path() -> str:
"""获取 MinerU 工具配置文件路径,支持环境变量指定绝对或相对路径。"""
config_file_name = os.getenv('MINERU_TOOLS_CONFIG_JSON', 'mineru.json')
if os.path.isabs(config_file_name):
return config_file_name
return os.path.join(os.path.expanduser('~'), config_file_name)
def download_json(url):
"""下载 JSON 文件并返回解析后的内容。"""
response = requests.get(url)
response.raise_for_status()
return response.json()
def is_config_version_outdated(config_version):
"""判断本地配置版本是否低于当前模板版本。"""
def version_tuple(version):
"""将版本号字符串转换为可比较的整数元组。"""
parts = []
for part in str(version).split('.'):
parts.append(int(part) if part.isdigit() else 0)
return tuple(parts)
current_version = version_tuple(config_version)
target_version = version_tuple(MINERU_CONFIG_VERSION)
max_len = max(len(current_version), len(target_version))
current_version += (0,) * (max_len - len(current_version))
target_version += (0,) * (max_len - len(target_version))
return current_version < target_version
def merge_config_dict(base_config: dict, override_config: dict, skip_keys: set[str] | None = None) -> dict:
"""递归合并配置字典,用 override_config 覆盖 base_config 并保留新模板字段。"""
skip_keys = skip_keys or set()
merged_config = dict(base_config)
for key, value in override_config.items():
if key in skip_keys:
continue
base_value = merged_config.get(key)
if isinstance(base_value, dict) and isinstance(value, dict):
merged_config[key] = merge_config_dict(base_value, value, skip_keys=skip_keys)
else:
merged_config[key] = value
return merged_config
def download_and_modify_json(url, local_filename, modifications):
"""下载或读取 JSON 配置,并按 modifications 合并更新后写回。"""
if os.path.exists(local_filename):
with open(local_filename, encoding='utf-8') as f:
data = json.load(f)
config_version = data.get('config_version', '0.0.0')
if is_config_version_outdated(config_version):
template_data = download_json(url)
data = merge_config_dict(template_data, data, skip_keys={'config_version'})
else:
data = download_json(url)
data = merge_config_dict(data, modifications)
with open(local_filename, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=4)
def persist_resolved_model_source(model_source: str) -> None:
"""将 auto 解析出的实际模型来源写入配置文件,避免下次启动时再次受网络波动影响。"""
if model_source not in REMOTE_MODEL_SOURCES:
return
try:
download_and_modify_json(
CONFIG_TEMPLATE_URL,
get_tools_config_file_path(),
{'model-source': model_source},
)
except Exception as exc:
logger.warning(f"Failed to persist resolved model source '{model_source}': {exc}")
def normalize_download_relative_path(relative_path: str, repo_mode: str) -> str:
"""按仓库模式规范化下载相对路径,保持 pipeline 与 VLM 原有路径语义。"""
if repo_mode == 'pipeline':
return relative_path.strip('/')
if repo_mode == 'vlm':
if relative_path == "/":
return relative_path
return relative_path.strip('/')
raise ValueError(f"Unsupported repo_mode: {repo_mode}, must be 'pipeline' or 'vlm'")
def read_existing_tools_config() -> dict | None:
"""读取已存在的工具 JSON 配置;不存在或读取失败时返回 None,不影响后续下载。"""
config_file = get_tools_config_file_path()
if not os.path.exists(config_file):
return None
try:
with open(config_file, encoding='utf-8') as f:
config = json.load(f)
except Exception as exc:
logger.warning(f"Failed to read model config from {config_file}: {exc}")
return None
if not isinstance(config, dict):
logger.warning(f"Model config in {config_file} must be a JSON object.")
return None
return config
def get_configured_repo_model_root(config: dict, repo_mode: str) -> str | None:
"""从 JSON 配置中获取指定仓库模式的模型根路径,缺失或类型不正确时返回 None。"""
models_dir = config.get('models-dir')
if not isinstance(models_dir, dict):
return None
model_root = models_dir.get(repo_mode)
if not isinstance(model_root, str):
return None
model_root = model_root.strip()
if not model_root:
return None
return os.path.expanduser(model_root)
def build_configured_model_path(model_root: str, relative_path: str) -> str:
"""根据模型根路径和本次下载相对路径拼出本地待检查路径。"""
if relative_path in ("", "/"):
return model_root
return os.path.join(model_root, relative_path)
def get_existing_configured_model_root(repo_mode: str, relative_path: str) -> str | None:
"""如果 JSON 中配置的模型路径已包含本次所需模型,则返回该模型根路径以跳过下载。"""
config = read_existing_tools_config()
if config is None:
return None
model_root = get_configured_repo_model_root(config, repo_mode)
if model_root is None:
return None
local_model_path = build_configured_model_path(model_root, relative_path)
if os.path.exists(local_model_path):
logger.debug(f"Use configured local {repo_mode} model path: {local_model_path}")
return model_root
return None
def persist_downloaded_model_config(model_source: str, repo_mode: str, model_root: str) -> None:
"""snapshot_download 成功后,创建或更新 JSON,写入本次模型根路径和实际来源。"""
config_file = get_tools_config_file_path()
try:
download_and_modify_json(
CONFIG_TEMPLATE_URL,
config_file,
{
'models-dir': {
repo_mode: model_root,
},
'model-source': model_source,
},
)
except Exception as exc:
logger.warning(
f"Failed to persist downloaded {repo_mode} model config "
f"to {config_file}: {exc}"
)
@lru_cache(maxsize=1)
def resolve_auto_model_source() -> str:
"""通过 Hugging Face 模型列表页探测 auto 应该使用的实际模型来源。"""
last_error = None
for _ in range(HUGGINGFACE_MODELS_PAGE_MAX_ATTEMPTS):
try:
response = requests.get(
HUGGINGFACE_MODELS_PAGE_URL,
timeout=HUGGINGFACE_MODELS_PAGE_TIMEOUT,
)
if 200 <= response.status_code < 400:
return "huggingface"
last_error = f"status_code={response.status_code}"
except Exception as exc:
last_error = str(exc)
logger.warning(
f"Failed to access {HUGGINGFACE_MODELS_PAGE_URL}: {last_error}, fallback to modelscope."
)
return "modelscope"
def resolve_model_source(model_source: str | None = None, allow_auto: bool = False) -> str:
"""将环境变量或配置文件中的模型来源解析为实际可下载的来源。"""
if model_source is None:
model_source = os.getenv(MODEL_SOURCE_ENV_VAR)
if isinstance(model_source, str) and model_source.strip().lower() == "auto":
raise ValueError(
f"{MODEL_SOURCE_ENV_VAR}=auto is not supported. "
f"Unset {MODEL_SOURCE_ENV_VAR} to use auto detection once, "
"or set it to huggingface/modelscope/local."
)
if model_source is None:
model_source = get_configured_model_source()
if model_source is None:
model_source = "auto"
allow_auto = True
if not isinstance(model_source, str):
logger.warning(f"Unsupported model source type: {type(model_source)}, fallback to auto.")
model_source = "auto"
allow_auto = True
normalized_model_source = model_source.strip().lower()
if normalized_model_source == "local":
return "local"
if normalized_model_source == "auto":
if not allow_auto:
raise ValueError(
"model source auto is only supported for internal default detection "
"or explicit download command selection."
)
resolved_model_source = resolve_auto_model_source()
persist_resolved_model_source(resolved_model_source)
return resolved_model_source
if normalized_model_source in REMOTE_MODEL_SOURCES:
return normalized_model_source
logger.warning(f"Unsupported model source: {model_source}, fallback to auto.")
resolved_model_source = resolve_auto_model_source()
persist_resolved_model_source(resolved_model_source)
return resolved_model_source
@lru_cache(maxsize=None)
def _snapshot_download_cached(model_source: str, repo_mode: str, repo: str, relative_path: str) -> str:
"""按进程缓存远端 snapshot_download 结果,减少重复缓存检查和 Fetching 日志。"""
if model_source == "huggingface":
snapshot_download = hf_snapshot_download
elif model_source == "modelscope":
snapshot_download = ms_snapshot_download
else:
raise ValueError(f"未知的仓库类型: {model_source}")
if repo_mode == 'pipeline':
cache_dir = snapshot_download(repo, allow_patterns=[relative_path, relative_path + "/*"])
elif repo_mode == 'vlm':
# VLM 整仓下载和局部路径下载都参与缓存,但保持原有 allow_patterns 行为。
if relative_path == "/":
cache_dir = snapshot_download(repo)
else:
cache_dir = snapshot_download(repo, allow_patterns=[relative_path, relative_path + "/*"])
else:
raise ValueError(f"Unsupported repo_mode: {repo_mode}, must be 'pipeline' or 'vlm'")
if cache_dir:
persist_downloaded_model_config(model_source, repo_mode, cache_dir)
return cache_dir
def auto_download_and_get_model_root_path(relative_path: str, repo_mode='pipeline') -> str:
"""
支持文件或目录的可靠下载。
@@ -15,7 +285,7 @@ def auto_download_and_get_model_root_path(relative_path: str, repo_mode='pipelin
:param relative_path: 文件或目录相对路径
:return: 本地文件绝对路径或相对路径
"""
model_source = os.getenv('MINERU_MODEL_SOURCE', "huggingface")
model_source = resolve_model_source()
if model_source == 'local':
local_models_config = get_local_models_dir()
@@ -28,42 +298,26 @@ def auto_download_and_get_model_root_path(relative_path: str, repo_mode='pipelin
repo_mapping = {
'pipeline': {
'huggingface': ModelPath.pipeline_root_hf,
'modelscope': ModelPath.pipeline_root_modelscope,
'default': ModelPath.pipeline_root_hf
'modelscope': ModelPath.pipeline_root_modelscope
},
'vlm': {
'huggingface': ModelPath.vlm_root_hf,
'modelscope': ModelPath.vlm_root_modelscope,
'default': ModelPath.vlm_root_hf
'modelscope': ModelPath.vlm_root_modelscope
}
}
if repo_mode not in repo_mapping:
raise ValueError(f"Unsupported repo_mode: {repo_mode}, must be 'pipeline' or 'vlm'")
# 如果没有指定model_source或值不是'modelscope',则使用默认值
repo = repo_mapping[repo_mode].get(model_source, repo_mapping[repo_mode]['default'])
# model_source 已解析为实际远端来源后,再选择对应仓库。
repo = repo_mapping[repo_mode][model_source]
relative_path = normalize_download_relative_path(relative_path, repo_mode)
configured_model_root = get_existing_configured_model_root(repo_mode, relative_path)
if configured_model_root is not None:
return configured_model_root
if model_source == "huggingface":
snapshot_download = hf_snapshot_download
elif model_source == "modelscope":
snapshot_download = ms_snapshot_download
else:
raise ValueError(f"未知的仓库类型: {model_source}")
cache_dir = None
if repo_mode == 'pipeline':
relative_path = relative_path.strip('/')
cache_dir = snapshot_download(repo, allow_patterns=[relative_path, relative_path+"/*"])
elif repo_mode == 'vlm':
# VLM 模式下,根据 relative_path 的不同处理方式
if relative_path == "/":
cache_dir = snapshot_download(repo)
else:
relative_path = relative_path.strip('/')
cache_dir = snapshot_download(repo, allow_patterns=[relative_path, relative_path+"/*"])
cache_dir = _snapshot_download_cached(model_source, repo_mode, repo, relative_path)
if not cache_dir:
raise FileNotFoundError(f"Failed to download model: {relative_path} from {repo}")
@@ -73,4 +327,4 @@ def auto_download_and_get_model_root_path(relative_path: str, repo_mode='pipelin
if __name__ == '__main__':
path1 = "models/README.md"
root = auto_download_and_get_model_root_path(path1)
print("本地文件绝对路径:", os.path.join(root, path1))
print("本地文件绝对路径:", os.path.join(root, path1))