project-halide / models /vision /minicpm_wrapper.py
Lonelyguyse1's picture
Deploy Project Halide Gradio Space
63c4f20 verified
Raw
History Blame Contribute Delete
7.55 kB
"""MiniCPM-V 4.6 wrapper. Loads the model and runs inference on film scans."""
from __future__ import annotations
import json
import logging
import os
import re
from typing import Any
from config import CHECKPOINT_DIR, get_vision_config, require_gpu_for_inference
from models.vision.prompts import DETECTION_PROMPT_INT
logger = logging.getLogger(__name__)
DETECTION_PROMPT = DETECTION_PROMPT_INT
def _resolve_model_path() -> str:
"""Pick configured fine-tuned model or public base model."""
cfg = get_vision_config()
explicit = os.getenv("HALIDE_VISION_MODEL_ID")
if explicit:
logger.info("Using explicit vision model %s", explicit)
return explicit
if cfg.use_finetuned:
local_candidates = [
cfg.local_model_path,
CHECKPOINT_DIR / "minicpm-v-4.6-merged",
]
seen: set[str] = set()
for path in local_candidates:
key = str(path.resolve())
if key in seen:
continue
seen.add(key)
if path.exists() and (path / "config.json").exists():
logger.info("Using local fine-tuned vision model at %s", path)
return str(path)
logger.info("Using fine-tuned vision model repo %s", cfg.finetuned_model_id)
return cfg.finetuned_model_id
logger.info("Using base vision model %s", cfg.base_model_id)
return cfg.base_model_id
class MiniCPMVDetector:
"""Lazy-loading wrapper around MiniCPM-V 4.6 for film defect detection."""
def __init__(self, model_path: str | None = None) -> None:
self._model_path = model_path or _resolve_model_path()
self._model: Any = None
self._processor: Any = None
self._dtype: Any = None
self._device: str = "cpu"
@property
def model_path(self) -> str:
return self._model_path
def load(self) -> None:
if self._model is not None:
return
require_gpu_for_inference("vision")
import torch
from transformers import AutoModelForImageTextToText, AutoProcessor
logger.info("Loading MiniCPM-V 4.6 from %s", self._model_path)
self._processor = AutoProcessor.from_pretrained(
self._model_path, trust_remote_code=True
)
self._dtype = _select_cuda_dtype(torch)
self._model = AutoModelForImageTextToText.from_pretrained(
self._model_path,
torch_dtype=self._dtype,
device_map="auto",
trust_remote_code=True,
)
self._device = str(next(self._model.parameters()).device)
logger.info("Model loaded on %s with dtype %s", self._device, self._dtype)
def detect(self, image: Any) -> dict:
"""Run defect detection on a PIL image. Returns parsed JSON dict."""
import torch
if self._model is None:
self.load()
cfg = get_vision_config()
messages = [
{
"role": "user",
"content": [
{"type": "image", "image": image},
{"type": "text", "text": DETECTION_PROMPT},
],
}
]
inputs = _apply_chat_template(
self._processor,
messages,
downsample_mode=cfg.downsample_mode,
max_slice_nums=cfg.max_slice_nums,
).to(self._device)
with torch.inference_mode():
generated = self._model.generate(
**inputs,
downsample_mode=cfg.downsample_mode,
max_new_tokens=cfg.max_new_tokens,
do_sample=False,
)
trimmed = [out[len(inp):] for inp, out in zip(inputs.input_ids, generated)]
text = self._processor.batch_decode(
trimmed,
skip_special_tokens=True,
clean_up_tokenization_spaces=False,
)[0]
return _parse_defect_json(text)
def close(self) -> None:
if self._model is not None:
del self._model
self._model = None
if self._processor is not None:
del self._processor
self._processor = None
def _parse_defect_json(text: str) -> dict:
"""Extract and parse the first JSON object from model output."""
text = text.strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE)
text = re.sub(r"\s*```$", "", text)
try:
parsed = json.loads(text)
if isinstance(parsed, list):
return {"defects": parsed}
if isinstance(parsed, dict):
return parsed
return {"defects": [], "_raw": text, "_parse_error": "json_not_object"}
except json.JSONDecodeError:
pass
match = re.search(r"\{[\s\S]*\}", text)
if not match:
logger.warning("No JSON found in model output: %r", text[:200])
return {"defects": [], "_raw": text, "_parse_error": "no_json_object"}
try:
parsed = json.loads(match.group(0))
if isinstance(parsed, dict):
return parsed
return {"defects": [], "_raw": text, "_parse_error": "json_not_object"}
except json.JSONDecodeError as exc:
fragments = _parse_defect_fragments(text)
if fragments:
logger.warning(
"Salvaged %s defect fragments from malformed JSON: %s",
len(fragments),
exc,
)
return {
"defects": fragments,
"_parse_error": str(exc),
"_parse_warning": "salvaged_defect_fragments",
}
logger.warning("JSON parse error: %s; raw: %r", exc, text[:200])
return {"defects": [], "_raw": text, "_parse_error": str(exc)}
def _parse_defect_fragments(text: str) -> list[dict[str, Any]]:
"""Recover complete defect objects from truncated JSON arrays."""
fragments: list[dict[str, Any]] = []
for match in re.finditer(r"\{[^{}]*\"label\"[^{}]*\"bbox\"\s*:\s*\[[^\]]+\][^{}]*\}", text):
try:
candidate = json.loads(match.group(0))
except json.JSONDecodeError:
continue
if isinstance(candidate, dict):
fragments.append(candidate)
return fragments
def _apply_chat_template(
processor: Any,
messages: list[dict],
*,
downsample_mode: str,
max_slice_nums: int,
) -> Any:
"""Call MiniCPM chat template across Transformers API variants."""
kwargs = {
"tokenize": True,
"add_generation_prompt": True,
"return_dict": True,
"return_tensors": "pt",
}
try:
return processor.apply_chat_template(
messages,
**kwargs,
downsample_mode=downsample_mode,
max_slice_nums=max_slice_nums,
)
except TypeError:
return processor.apply_chat_template(
messages,
**kwargs,
processor_kwargs={
"downsample_mode": downsample_mode,
"max_slice_nums": max_slice_nums,
},
)
def _select_cuda_dtype(torch_module: Any) -> Any:
major, _minor = torch_module.cuda.get_device_capability()
if major >= 8:
return torch_module.bfloat16
return torch_module.float16
_default_detector: MiniCPMVDetector | None = None
def get_detector() -> MiniCPMVDetector:
global _default_detector
if _default_detector is None:
_default_detector = MiniCPMVDetector()
return _default_detector