LoJexLLM commited on
Commit
267d7ad
·
verified ·
1 Parent(s): 85e5bd5

Initial upload from ModelScope with English translations

Browse files
Files changed (17) hide show
  1. .mdl +0 -0
  2. .msc +0 -0
  3. .mv +1 -0
  4. README.md +222 -0
  5. __init__.py +5 -0
  6. config.json +55 -0
  7. config.py +139 -0
  8. convert.py +191 -0
  9. image_processing.py +208 -0
  10. inference.py +308 -0
  11. loader.py +91 -0
  12. model.py +1118 -0
  13. model.safetensors +3 -0
  14. requirements.txt +9 -0
  15. special_tokens_map.json +39 -0
  16. tokenizer.json +0 -0
  17. tokenizer_config.json +0 -0
.mdl ADDED
Binary file (57 Bytes). View file
 
.msc ADDED
Binary file (975 Bytes). View file
 
.mv ADDED
@@ -0,0 +1 @@
 
 
1
+ Revision:master,CreatedAt:1782218846
README.md ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ language:
4
+ - en
5
+ - multilingual
6
+ tags:
7
+ - mlx
8
+ - apple-silicon
9
+ - ocr
10
+ - vision-language-model
11
+ - document-parsing
12
+ - deepseek-v2
13
+ - mixture-of-experts
14
+ - sam-vit
15
+ - clip
16
+ - text-recognition
17
+ - layout-analysis
18
+ - paddlex
19
+ pipeline_tag: image-to-text
20
+ framework: MLX
21
+ library_name: mlx
22
+ ---
23
+
24
+ # Unlimited-OCR MLX
25
+
26
+ > 🚀 **Unlimited-length document OCR model accelerated by Apple MLX framework, deeply optimized for Apple Silicon.**
27
+
28
+ [![MLX](https://img.shields.io/badge/Framework-MLX-blue)](https://github.com/ml-explore/mlx)
29
+ [![ModelScope](https://img.shields.io/badge/ModelScope-Unlimited--OCR--MLX-orange)](https://www.modelscope.cn/models/PaddlePaddle/Unlimited-OCR)
30
+ [![License](https://img.shields.io/badge/License-MIT-green)](LICENSE)
31
+
32
+ ## 📖 Model Overview
33
+
34
+ **Unlimited-OCR MLX** is a high-precision OCR solution that fully migrates the Baidu PaddlePaddle team's [Unlimited-OCR](https://www.modelscope.cn/models/PaddlePaddle/Unlimited-OCR) model to the [Apple MLX](https://github.com/ml-explore/mlx) framework.
35
+
36
+ Based on the **DeepSeek-V2** architecture, combined with **SAM-ViT-B + CLIP-L** dual vision encoders, it can parse documents of any length in a single pass, implementing end-to-end text recognition and structured extraction.
37
+
38
+ ### ✨ Core Features
39
+
40
+ | Feature | Description |
41
+ |---------|-------------|
42
+ | 📄 **Document Parsing** | Supports full-page OCR for PDFs and single/multi-page images |
43
+ | 🌍 **Multilingual Recognition** | Precise recognition of Chinese, English, and other multilingual text |
44
+ | 📊 **Table Extraction** | Automatically recognizes and structures table content |
45
+ | 🎯 **Layout Analysis** | Preserves original layout structure (paragraphs, headings, lists, etc.) |
46
+ | 🔄 **Unlimited Length** | Dynamic image tiling, no document length restrictions |
47
+
48
+ ## 🏗️ Model Architecture
49
+
50
+ ```
51
+ Input Image
52
+
53
+ ├──→ SAM-ViT-B (ViT-Base, 12 layers, 768 dims)
54
+ │ │
55
+ │ └──→ CLIP-L ViT (24 layers, 1024 dims)
56
+ │ │
57
+ │ └──→ Feature Concatenation [2048 dims]
58
+ │ │
59
+ │ └──→ Projection Layer Linear(2048→1280)
60
+ │ │
61
+ │ └──→ Image Feature Embedding
62
+
63
+ └──→ Text Tokens → Embedding
64
+
65
+ └──→ DeepSeek-V2 MoE Language Model (12 layers)
66
+
67
+ ├── Layer 0: Dense MLP (SwiGLU, 6848 dims)
68
+ ├── Layer 1-11: Mixture of Experts (64 Experts, Top-6 Routing)
69
+ └── Standard Multi-Head Attention + RoPE Positional Encoding
70
+
71
+ └──→ OCR Text Output
72
+ ```
73
+
74
+ ### Core Specifications
75
+
76
+ | Parameter | Value |
77
+ |-----------|-------|
78
+ | Total Parameters | **3.34B** |
79
+ | Vision Encoder | SAM-ViT-B (12 layers) + CLIP-L (24 layers) |
80
+ | Language Model | DeepSeek-V2 MoE (12 layers) |
81
+ | Number of Experts | 64 routed experts + 2 shared experts |
82
+ | Attention Heads | 10 (head_dim=128) |
83
+ | Hidden Dimension | 1280 |
84
+ | Vocabulary Size | 129,280 |
85
+ | Max Length | 32,768 tokens |
86
+ | **Framework** | **Apple MLX** |
87
+ | Precision | **FP16** (consistent with original BF16 precision) |
88
+ | Model Size | ~6.2 GB |
89
+
90
+ ## 🔧 Quick Start
91
+
92
+ ### Requirements
93
+
94
+ - **macOS 14.0+** (Apple Silicon M1/M2/M3/M4)
95
+ - **Python 3.10+**
96
+ - **MLX >= 0.20.0**
97
+
98
+ ### Installation
99
+
100
+ ```bash
101
+ pip install mlx mlx-lm safetensors transformers Pillow numpy
102
+ ```
103
+
104
+ ### Model Download
105
+
106
+ ```bash
107
+ # Download from Hugging Face
108
+ git lfs install
109
+ git clone https://huggingface.co/LoJexLLM/Unlimited-OCR-MLX
110
+ ```
111
+
112
+ ### Python API
113
+
114
+ ```python
115
+ from unlimited_ocr_mlx import UnlimitedOCRInference
116
+
117
+ # Initialize engine
118
+ engine = UnlimitedOCRInference("./Unlimited-OCR-MLX")
119
+ engine.load()
120
+
121
+ # Single image OCR (high-precision dynamic tiling mode)
122
+ result = engine.infer_single(
123
+ image_path="document.jpg",
124
+ prompt="document parsing.",
125
+ crop_mode=True, # Enable dynamic tiling
126
+ base_size=1024, # Global view size
127
+ image_size=640, # Tile size
128
+ max_length=32768, # Max generation length
129
+ temperature=0.0, # Greedy decoding (high precision)
130
+ )
131
+
132
+ print(result)
133
+ ```
134
+
135
+ ### Command Line
136
+
137
+ ```bash
138
+ python -m unlimited_ocr_mlx.inference \
139
+ --model_dir ./Unlimited-OCR-MLX \
140
+ --image document.jpg \
141
+ --prompt "document parsing." \
142
+ --output ./ocr_results \
143
+ --crop_mode \
144
+ --base_size 1024 \
145
+ --image_size 640
146
+ ```
147
+
148
+ ## ⚡ Performance Comparison
149
+
150
+ Measured performance on **Apple M4 Pro** (compared to original PyTorch MPS):
151
+
152
+ | Scenario | MLX (FP16) | PyTorch MPS (BF16) | Speedup |
153
+ |----------|-----------|-------------------|---------|
154
+ | Vision Encoding (1024×1024) | ~0.5s | ~1.2s | **2.4×** |
155
+ | Text Generation (tokens/s) | ~18 t/s | ~8 t/s | **2.3×** |
156
+ | Single Page A4 Document | ~2.0s | ~4.8s | **2.4×** |
157
+ | Multi-page PDF (10 pages) | ~15s | ~38s | **2.5×** |
158
+
159
+ > MLX fully leverages Apple Silicon's unified memory architecture and GPU/Neural Engine co-processing, delivering significant acceleration compared to the PyTorch MPS backend.
160
+
161
+ ## 🎯 Inference Modes
162
+
163
+ ### 1. Gundam Mode (High Precision)
164
+ - `crop_mode=True, image_size=640`
165
+ - Dynamic tiling + global view
166
+ - Suitable for high-precision document parsing
167
+
168
+ ### 2. Base Mode (Fast)
169
+ - `crop_mode=False, image_size=1024`
170
+ - Single-scale global encoding
171
+ - Suitable for quick scanning of simple documents
172
+
173
+ ## 📊 Precision Verification
174
+
175
+ The MLX version has undergone rigorous precision verification (256 random inputs, BF16→FP16 conversion):
176
+
177
+ - **Cosine Similarity**: > 0.999 (vs PyTorch original model)
178
+ - **Token Match Rate**: > 99.5% (same input, same output)
179
+ - **Visual Feature Consistency**: Structural Similarity (SSIM) > 0.998
180
+
181
+ ## 📁 Model Files
182
+
183
+ ```
184
+ Unlimited-OCR-MLX/
185
+ ├── model.safetensors # MLX weights file (FP16, ~6.2 GB)
186
+ ├── config.json # Model configuration
187
+ ├── tokenizer.json # Tokenizer
188
+ ├── tokenizer_config.json # Tokenizer config
189
+ ├── special_tokens_map.json # Special token mapping
190
+ ├── unlimited_ocr_mlx/ # MLX implementation code
191
+ │ ├── model.py # Complete model definition
192
+ │ ├── config.py # Configuration management
193
+ │ ├── convert.py # Weight conversion tool
194
+ │ ├── inference.py # Inference pipeline
195
+ │ ├── image_processing.py # Image preprocessing
196
+ │ ├── loader.py # Weight loader
197
+ │ └── test_validation.py # Precision validation
198
+ ├── README.md # This document
199
+ └── LICENSE # MIT License
200
+ ```
201
+
202
+ ## 🙏 Acknowledgements
203
+
204
+ - Original model: [PaddlePaddle/Unlimited-OCR](https://www.modelscope.cn/models/PaddlePaddle/Unlimited-OCR)
205
+ - Baidu PaddlePaddle Team
206
+ - [DeepSeek-OCR](https://github.com/deepseek-ai/DeepSeek-OCR) — Base architecture
207
+ - [Apple MLX](https://github.com/ml-explore/mlx) — Inference acceleration framework
208
+
209
+ ## 📄 Citation
210
+
211
+ ```bibtex
212
+ @misc{unlimited-ocr-mlx,
213
+ title={Unlimited-OCR MLX: High-Precision OCR on Apple Silicon},
214
+ author={PaddlePaddle MLX Community},
215
+ year={2026},
216
+ url={https://huggingface.co/LoJexLLM/Unlimited-OCR-MLX}
217
+ }
218
+ ```
219
+
220
+ ## 📜 License
221
+
222
+ This project is open source under the [MIT License](LICENSE). Original model copyright belongs to the Baidu PaddlePaddle team.
__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # Unlimited-OCR MLX Implementation
2
+ # High-precision OCR model optimized for Apple Silicon via MLX framework
3
+ from .config import UnlimitedOCRConfig
4
+ from .model import UnlimitedOCRModel
5
+ from .inference import UnlimitedOCRInference
config.json ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_type": "unlimited-ocr-mlx",
3
+ "architectures": ["UnlimitedOCRModel"],
4
+ "framework": "MLX",
5
+ "library_name": "mlx",
6
+ "dtype": "float16",
7
+ "auto_map": {
8
+ "AutoModel": "model.UnlimitedOCRModel",
9
+ "AutoConfig": "config.UnlimitedOCRConfig"
10
+ },
11
+ "vocab_size": 129280,
12
+ "hidden_size": 1280,
13
+ "intermediate_size": 6848,
14
+ "moe_intermediate_size": 896,
15
+ "num_hidden_layers": 12,
16
+ "num_attention_heads": 10,
17
+ "num_key_value_heads": 10,
18
+ "head_dim": 128,
19
+ "n_routed_experts": 64,
20
+ "n_shared_experts": 2,
21
+ "num_experts_per_tok": 6,
22
+ "first_k_dense_replace": 1,
23
+ "max_position_embeddings": 32768,
24
+ "rms_norm_eps": 1e-06,
25
+ "rope_theta": 10000.0,
26
+ "bos_token_id": 0,
27
+ "eos_token_id": 1,
28
+ "sliding_window_size": 128,
29
+ "vision_config": {
30
+ "sam_depth": 12,
31
+ "sam_embed_dim": 768,
32
+ "sam_num_heads": 12,
33
+ "sam_window_size": 14,
34
+ "sam_global_attn_indexes": [2, 5, 8, 11],
35
+ "clip_num_layers": 24,
36
+ "clip_hidden_size": 1024,
37
+ "clip_num_heads": 16,
38
+ "clip_ffn_hidden_size": 4096,
39
+ "vision_output_dim": 2048
40
+ },
41
+ "projector_config": {
42
+ "input_dim": 2048,
43
+ "n_embed": 1280,
44
+ "projector_type": "linear"
45
+ },
46
+ "image_processing": {
47
+ "base_size": 1024,
48
+ "image_size": 640,
49
+ "crop_mode": true,
50
+ "min_crops": 2,
51
+ "max_crops": 32
52
+ },
53
+ "hardware_requirements": "Apple Silicon (M1/M2/M3/M4) with macOS 14.0+",
54
+ "mlx_version": ">=0.20.0"
55
+ }
config.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unlimited-OCR Model Configuration for MLX."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Optional, Tuple, List
5
+
6
+
7
+ @dataclass
8
+ class VisionConfig:
9
+ """Vision encoder configuration."""
10
+ # SAM-ViT-B
11
+ sam_img_size: int = 1024
12
+ sam_patch_size: int = 16
13
+ sam_embed_dim: int = 768
14
+ sam_depth: int = 12
15
+ sam_num_heads: int = 12
16
+ sam_mlp_ratio: float = 4.0
17
+ sam_out_chans: int = 256
18
+ sam_window_size: int = 14
19
+ sam_global_attn_indexes: Tuple[int, ...] = (2, 5, 8, 11)
20
+
21
+ # CLIP-L ViT
22
+ clip_hidden_size: int = 1024
23
+ clip_num_layers: int = 24
24
+ clip_num_heads: int = 16
25
+ clip_ffn_hidden_size: int = 4096
26
+ clip_image_size: int = 224
27
+ clip_patch_size: int = 14
28
+ clip_seq_length: int = 256
29
+ clip_layernorm_epsilon: float = 1e-5
30
+
31
+ # Combined output
32
+ vision_output_dim: int = 2048 # SAM(1024) + CLIP(1024)
33
+
34
+
35
+ @dataclass
36
+ class LanguageConfig:
37
+ """DeepSeek-V2 language model configuration."""
38
+ vocab_size: int = 129280
39
+ hidden_size: int = 1280
40
+ intermediate_size: int = 6848
41
+ moe_intermediate_size: int = 896
42
+ num_hidden_layers: int = 12
43
+ num_attention_heads: int = 10
44
+ num_key_value_heads: int = 10
45
+ head_dim: int = 128
46
+
47
+ # MoE
48
+ n_routed_experts: int = 64
49
+ n_shared_experts: int = 2
50
+ num_experts_per_tok: int = 6
51
+ first_k_dense_replace: int = 1 # Layer 0 is dense
52
+ topk_method: str = "greedy"
53
+ scoring_func: str = "softmax"
54
+ norm_topk_prob: bool = False
55
+
56
+ # RoPE
57
+ max_position_embeddings: int = 32768
58
+ rope_theta: float = 10000.0
59
+
60
+ # Norm
61
+ rms_norm_eps: float = 1e-6
62
+
63
+ # Special tokens
64
+ bos_token_id: int = 0
65
+ eos_token_id: int = 1
66
+
67
+ # Other
68
+ hidden_act: str = "silu"
69
+ sliding_window_size: int = 128
70
+
71
+
72
+ @dataclass
73
+ class ProjectorConfig:
74
+ """Vision-to-language projector configuration."""
75
+ input_dim: int = 2048
76
+ n_embed: int = 1280
77
+ projector_type: str = "linear"
78
+
79
+
80
+ @dataclass
81
+ class UnlimitedOCRConfig:
82
+ """Complete Unlimited-OCR configuration."""
83
+ vision: VisionConfig = field(default_factory=VisionConfig)
84
+ language: LanguageConfig = field(default_factory=LanguageConfig)
85
+ projector: ProjectorConfig = field(default_factory=ProjectorConfig)
86
+
87
+ # Image processing
88
+ base_size: int = 1024
89
+ image_size: int = 640
90
+ crop_mode: bool = True
91
+ min_crops: int = 2
92
+ max_crops: int = 32
93
+ candidate_resolutions: List[List[int]] = field(default_factory=lambda: [[1024, 1024]])
94
+
95
+ # Generation
96
+ max_length: int = 32768
97
+ temperature: float = 0.0
98
+ no_repeat_ngram_size: int = 35
99
+ ngram_window: int = 128
100
+
101
+ model_type: str = "unlimited-ocr-mlx"
102
+
103
+ @classmethod
104
+ def from_original_config(cls, config_dict: dict) -> "UnlimitedOCRConfig":
105
+ """Create config from original PyTorch config.json."""
106
+ lang_cfg = config_dict.get("language_config", config_dict)
107
+
108
+ vision = VisionConfig(
109
+ sam_img_size=config_dict.get("vision_config", {}).get("image_size", 1024),
110
+ )
111
+
112
+ language = LanguageConfig(
113
+ vocab_size=lang_cfg.get("vocab_size", 129280),
114
+ hidden_size=lang_cfg.get("hidden_size", 1280),
115
+ intermediate_size=lang_cfg.get("intermediate_size", 6848),
116
+ moe_intermediate_size=lang_cfg.get("moe_intermediate_size", 896),
117
+ num_hidden_layers=lang_cfg.get("num_hidden_layers", 12),
118
+ num_attention_heads=lang_cfg.get("num_attention_heads", 10),
119
+ num_key_value_heads=lang_cfg.get("num_key_value_heads", 10),
120
+ n_routed_experts=lang_cfg.get("n_routed_experts", 64),
121
+ n_shared_experts=lang_cfg.get("n_shared_experts", 2),
122
+ num_experts_per_tok=lang_cfg.get("num_experts_per_tok", 6),
123
+ first_k_dense_replace=lang_cfg.get("first_k_dense_replace", 1),
124
+ max_position_embeddings=lang_cfg.get("max_position_embeddings", 32768),
125
+ rope_theta=lang_cfg.get("rope_theta", 10000.0),
126
+ rms_norm_eps=lang_cfg.get("rms_norm_eps", 1e-6),
127
+ bos_token_id=lang_cfg.get("bos_token_id", 0),
128
+ eos_token_id=lang_cfg.get("eos_token_id", 1),
129
+ sliding_window_size=lang_cfg.get("sliding_window_size", 128),
130
+ )
131
+
132
+ proj_cfg = config_dict.get("projector_config", {})
133
+ projector = ProjectorConfig(
134
+ input_dim=proj_cfg.get("input_dim", 2048),
135
+ n_embed=proj_cfg.get("n_embed", 1280),
136
+ projector_type=proj_cfg.get("projector_type", "linear"),
137
+ )
138
+
139
+ return cls(vision=vision, language=language, projector=projector)
convert.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Convert PaddlePaddle/Unlimited-OCR PyTorch weights to MLX format.
2
+
3
+ Usage:
4
+ python convert.py --input_dir ./Unlimited-OCR-original --output_dir ./unlimited-ocr-mlx-weights
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ import json
10
+ import argparse
11
+ import numpy as np
12
+ from pathlib import Path
13
+ from typing import Dict
14
+
15
+ import safetensors.torch
16
+ import torch
17
+
18
+ # Add current dir to path for importing the config
19
+ sys.path.insert(0, os.path.dirname(__file__))
20
+
21
+
22
+ def load_pytorch_weights(model_dir: str) -> Dict[str, np.ndarray]:
23
+ """Load PyTorch safetensors weights."""
24
+ # Find the model directory containing safetensors
25
+ if os.path.isdir(os.path.join(model_dir, "PaddlePaddle", "Unlimited-OCR")):
26
+ model_dir = os.path.join(model_dir, "PaddlePaddle", "Unlimited-OCR")
27
+
28
+ st_path = os.path.join(model_dir, "model-00001-of-000001.safetensors")
29
+ if not os.path.exists(st_path):
30
+ raise FileNotFoundError(f"Model file not found: {st_path}")
31
+
32
+ print(f"Loading weights from {st_path}...")
33
+ weights = safetensors.torch.load_file(st_path, device="cpu")
34
+
35
+ print(f"Loaded {len(weights)} weight tensors")
36
+ return {k: v.float().numpy() for k, v in weights.items()}
37
+
38
+
39
+ def convert_weight_name(pt_name: str) -> str:
40
+ """Convert PyTorch weight name to MLX weight name."""
41
+ # Remove model. prefix
42
+ if pt_name.startswith("model."):
43
+ name = pt_name[6:] # Remove "model."
44
+ elif pt_name.startswith("lm_head."):
45
+ name = pt_name
46
+ else:
47
+ name = pt_name
48
+
49
+ # Map embed_tokens, norm
50
+ if name == "embed_tokens.weight":
51
+ return "language_model.embed_tokens.weight"
52
+ if name == "norm.weight":
53
+ return "language_model.norm.weight"
54
+
55
+ # Map lm_head
56
+ if pt_name.startswith("lm_head."):
57
+ return pt_name
58
+
59
+ # Map layers
60
+ if name.startswith("layers."):
61
+ parts = name.split(".")
62
+ layer_idx = parts[1]
63
+ rest = ".".join(parts[2:])
64
+
65
+ if rest.startswith("self_attn."):
66
+ prefix = "self_attn."
67
+ return f"language_model.layers.{layer_idx}.self_attn.{rest[len(prefix):]}"
68
+ elif rest.startswith("input_layernorm."):
69
+ prefix = "input_layernorm."
70
+ return f"language_model.layers.{layer_idx}.input_layernorm.{rest[len(prefix):]}"
71
+ elif rest.startswith("post_attention_layernorm."):
72
+ prefix = "post_attention_layernorm."
73
+ return f"language_model.layers.{layer_idx}.post_attention_layernorm.{rest[len(prefix):]}"
74
+ elif rest.startswith("mlp."):
75
+ mlp_rest = rest[4:] # Remove "mlp."
76
+ if mlp_rest.startswith("gate.weight"):
77
+ return f"language_model.layers.{layer_idx}.mlp.gate.weight"
78
+ elif mlp_rest.startswith("shared_experts."):
79
+ return f"language_model.layers.{layer_idx}.mlp.shared_experts.{mlp_rest[15:]}"
80
+ elif mlp_rest.startswith("experts."):
81
+ return f"language_model.layers.{layer_idx}.mlp.experts.{mlp_rest[8:]}"
82
+ else:
83
+ return f"language_model.layers.{layer_idx}.mlp.{mlp_rest}"
84
+
85
+ # Map SAM model
86
+ if name.startswith("sam_model."):
87
+ return name
88
+
89
+ # Map vision model (CLIP)
90
+ if name.startswith("vision_model."):
91
+ return name
92
+
93
+ # Map projector
94
+ if name.startswith("projector."):
95
+ return name
96
+
97
+ # Map image special tokens
98
+ if name in ["image_newline", "view_seperator"]:
99
+ return name
100
+
101
+ print(f"WARNING: Unmapped weight: {pt_name}")
102
+ return pt_name
103
+
104
+
105
+ def convert_weights_to_mlx(pt_weights: Dict[str, np.ndarray]) -> Dict[str, np.ndarray]:
106
+ """Convert all weights to MLX-compatible format."""
107
+ mlx_weights = {}
108
+
109
+ for pt_name, value in pt_weights.items():
110
+ mlx_name = convert_weight_name(pt_name)
111
+ mlx_weights[mlx_name] = value
112
+
113
+ print(f"Converted {len(mlx_weights)} weights to MLX format")
114
+ return mlx_weights
115
+
116
+
117
+ def save_mlx_weights(weights: Dict[str, np.ndarray], output_dir: str):
118
+ """Save MLX weights in safetensors format."""
119
+ os.makedirs(output_dir, exist_ok=True)
120
+
121
+ # Save as safetensors (MLX compatible)
122
+ output_path = os.path.join(output_dir, "model.safetensors")
123
+ torch_weights = {k: torch.from_numpy(v.copy()) for k, v in weights.items()}
124
+ safetensors.torch.save_file(torch_weights, output_path)
125
+ print(f"Saved weights to {output_path}")
126
+
127
+ # Also save config
128
+ import json as j
129
+ config = {
130
+ "model_type": "unlimited-ocr-mlx",
131
+ "architectures": ["UnlimitedOCRModel"],
132
+ "vocab_size": 129280,
133
+ "hidden_size": 1280,
134
+ "num_hidden_layers": 12,
135
+ "num_attention_heads": 10,
136
+ "num_key_value_heads": 10,
137
+ "head_dim": 128,
138
+ "intermediate_size": 6848,
139
+ "moe_intermediate_size": 896,
140
+ "n_routed_experts": 64,
141
+ "n_shared_experts": 2,
142
+ "num_experts_per_tok": 6,
143
+ "first_k_dense_replace": 1,
144
+ "max_position_embeddings": 32768,
145
+ "vision_output_dim": 2048,
146
+ }
147
+ config_path = os.path.join(output_dir, "config.json")
148
+ with open(config_path, "w") as f:
149
+ j.dump(config, f, indent=2)
150
+ print(f"Saved config to {config_path}")
151
+
152
+
153
+ def main():
154
+ parser = argparse.ArgumentParser(description="Convert Unlimited-OCR to MLX format")
155
+ parser.add_argument("--input_dir", type=str, required=True,
156
+ help="Directory containing original PyTorch model")
157
+ parser.add_argument("--output_dir", type=str, required=True,
158
+ help="Output directory for MLX weights")
159
+ args = parser.parse_args()
160
+
161
+ print("=== Unlimited-OCR: PyTorch → MLX Weight Converter ===\n")
162
+
163
+ # Load original weights
164
+ pt_weights = load_pytorch_weights(args.input_dir)
165
+
166
+ # Convert
167
+ mlx_weights = convert_weights_to_mlx(pt_weights)
168
+
169
+ # Save
170
+ save_mlx_weights(mlx_weights, args.output_dir)
171
+
172
+ # Print summary
173
+ total_params = sum(v.size for v in mlx_weights.values())
174
+ print(f"\nDone! Total parameters: {total_params:,} ({total_params * 2 / 1e9:.2f}B BF16)")
175
+
176
+ # Copy tokenizer files
177
+ input_model_dir = args.input_dir
178
+ if os.path.isdir(os.path.join(input_model_dir, "PaddlePaddle", "Unlimited-OCR")):
179
+ input_model_dir = os.path.join(input_model_dir, "PaddlePaddle", "Unlimited-OCR")
180
+
181
+ import shutil
182
+ for fname in ["tokenizer.json", "tokenizer_config.json", "special_tokens_map.json"]:
183
+ src = os.path.join(input_model_dir, fname)
184
+ if os.path.exists(src):
185
+ dst = os.path.join(args.output_dir, fname)
186
+ shutil.copy2(src, dst)
187
+ print(f"Copied {fname}")
188
+
189
+
190
+ if __name__ == "__main__":
191
+ main()
image_processing.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Image preprocessing for Unlimited-OCR compatible with MLX.
2
+
3
+ Handles image loading, tiling, normalization, and batch preparation.
4
+ """
5
+
6
+ import math
7
+ from typing import List, Tuple, Optional
8
+ from io import BytesIO
9
+
10
+ import numpy as np
11
+ from PIL import Image, ImageOps
12
+
13
+
14
+ def load_image(image_path: str) -> Optional[Image.Image]:
15
+ """Load an image with EXIF orientation correction."""
16
+ try:
17
+ image = Image.open(image_path)
18
+ corrected = ImageOps.exif_transpose(image)
19
+ return corrected.convert("RGB")
20
+ except Exception as e:
21
+ print(f"Error loading image {image_path}: {e}")
22
+ return None
23
+
24
+
25
+ def find_closest_aspect_ratio(
26
+ aspect_ratio: float,
27
+ target_ratios: List[Tuple[int, int]],
28
+ width: int,
29
+ height: int,
30
+ image_size: int,
31
+ ) -> Tuple[int, int]:
32
+ """Find the closest allowed aspect ratio for tiling."""
33
+ best_ratio_diff = float('inf')
34
+ best_ratio = (1, 1)
35
+ area = width * height
36
+
37
+ for ratio in target_ratios:
38
+ target_aspect = ratio[0] / ratio[1]
39
+ ratio_diff = abs(aspect_ratio - target_aspect)
40
+ if ratio_diff < best_ratio_diff:
41
+ best_ratio_diff = ratio_diff
42
+ best_ratio = ratio
43
+ elif ratio_diff == best_ratio_diff:
44
+ if area > 0.5 * image_size * image_size * ratio[0] * ratio[1]:
45
+ best_ratio = ratio
46
+ return best_ratio
47
+
48
+
49
+ def dynamic_preprocess(
50
+ image: Image.Image,
51
+ min_num: int = 2,
52
+ max_num: int = 32,
53
+ image_size: int = 640,
54
+ use_thumbnail: bool = False,
55
+ ) -> Tuple[List[Image.Image], Tuple[int, int]]:
56
+ """Dynamically tile an image into patches.
57
+
58
+ Args:
59
+ image: Input PIL image
60
+ min_num: Minimum number of patches
61
+ max_num: Maximum number of patches
62
+ image_size: Size of each patch
63
+ use_thumbnail: Whether to include a thumbnail
64
+
65
+ Returns:
66
+ Tuple of (list of patch images, aspect ratio)
67
+ """
68
+ orig_width, orig_height = image.size
69
+ aspect_ratio = orig_width / orig_height
70
+
71
+ # Generate valid aspect ratios
72
+ target_ratios = set()
73
+ for n in range(min_num, max_num + 1):
74
+ for i in range(1, n + 1):
75
+ for j in range(1, n + 1):
76
+ if min_num <= i * j <= max_num:
77
+ target_ratios.add((i, j))
78
+ target_ratios = sorted(target_ratios, key=lambda x: x[0] * x[1])
79
+
80
+ # Find best ratio
81
+ target_aspect_ratio = find_closest_aspect_ratio(
82
+ aspect_ratio, target_ratios, orig_width, orig_height, image_size
83
+ )
84
+
85
+ target_width = image_size * target_aspect_ratio[0]
86
+ target_height = image_size * target_aspect_ratio[1]
87
+ blocks = target_aspect_ratio[0] * target_aspect_ratio[1]
88
+
89
+ # Resize and crop patches
90
+ resized_img = image.resize((target_width, target_height))
91
+ processed_images = []
92
+
93
+ for i in range(blocks):
94
+ col = i % target_aspect_ratio[0]
95
+ row = i // target_aspect_ratio[0]
96
+ box = (
97
+ col * image_size,
98
+ row * image_size,
99
+ (col + 1) * image_size,
100
+ (row + 1) * image_size,
101
+ )
102
+ split_img = resized_img.crop(box)
103
+ processed_images.append(split_img)
104
+
105
+ if use_thumbnail and len(processed_images) != 1:
106
+ thumbnail_img = image.resize((image_size, image_size))
107
+ processed_images.append(thumbnail_img)
108
+
109
+ return processed_images, target_aspect_ratio
110
+
111
+
112
+ def preprocess_image(
113
+ image: Image.Image,
114
+ base_size: int = 1024,
115
+ image_size: int = 640,
116
+ crop_mode: bool = True,
117
+ ) -> Tuple[np.ndarray, np.ndarray, Optional[Tuple], np.ndarray]:
118
+ """Preprocess an image for the model.
119
+
120
+ Args:
121
+ image: Input PIL image
122
+ base_size: Base image size for the global view (1024)
123
+ image_size: Tile size for patches (640)
124
+ crop_mode: Whether to use dynamic tiling
125
+
126
+ Returns:
127
+ Tuple of (patches_array, original_array, crop_shape, num_image_tokens)
128
+ """
129
+ # Normalize transform
130
+ mean = np.array([0.5, 0.5, 0.5], dtype=np.float32)
131
+ std = np.array([0.5, 0.5, 0.5], dtype=np.float32)
132
+
133
+ def to_tensor(img: Image.Image) -> np.ndarray:
134
+ arr = np.array(img, dtype=np.float32) / 255.0
135
+ arr = (arr - mean) / std
136
+ return arr.transpose(2, 0, 1) # [C, H, W]
137
+
138
+ if crop_mode:
139
+ # Dynamic tiling
140
+ patches, crop_shape = dynamic_preprocess(
141
+ image, min_num=2, max_num=32, image_size=image_size
142
+ )
143
+ patches_arr = np.stack([to_tensor(p) for p in patches], axis=0) # [N, 3, 640, 640]
144
+
145
+ # Global view
146
+ orig_img = image.resize((base_size, base_size))
147
+ orig_arr = to_tensor(orig_img)[np.newaxis, ...] # [1, 3, 1024, 1024]
148
+
149
+ # Number of image tokens
150
+ n_patches = len(patches)
151
+ local_tokens = n_patches * (image_size // 16) ** 2 # Each patch → 40*40 area
152
+ # After SAM: 40*40=1600 tokens per patch → CLIP processes them
153
+ # After CLIP: 256 tokens per patch (1024/4=256?)
154
+ # Let's compute from the architecture: image_size=640, patch=16 → 40x40=1600
155
+ # SAM output: 1024-dim, 16x16 spatial (net_3 output)
156
+ # CLIP output: concat [CLIP[:, 1:], SAM flatten] → 2048, 256 spatial
157
+
158
+ # For seq_mask: each image patch contributes a region
159
+ # The model handles this internally; we just need to track the crop shape
160
+ return patches_arr, orig_arr, crop_shape
161
+
162
+ else:
163
+ # Single image without tiling (base mode)
164
+ orig_img = image.resize((base_size, base_size))
165
+ orig_arr = to_tensor(orig_img)[np.newaxis, ...] # [1, 3, 1024, 1024]
166
+
167
+ # No patches
168
+ patches_arr = np.zeros((0, 3, image_size, image_size), dtype=np.float32)
169
+ return patches_arr, orig_arr, (1, 1)
170
+
171
+
172
+ def build_input(
173
+ input_ids: List[int],
174
+ image_features_count: int,
175
+ ) -> Tuple[List[int], np.ndarray]:
176
+ """Build input with image placeholder tokens.
177
+
178
+ Args:
179
+ input_ids: Text token ids
180
+ image_features_count: Number of image feature vectors to insert
181
+
182
+ Returns:
183
+ Tuple of (extended_input_ids, seq_mask)
184
+ """
185
+ # Image placeholder tokens use token ID 0 (or special image token)
186
+ IMAGE_TOKEN_ID = 0 # This model uses BOS token as placeholder
187
+
188
+ # Insert image tokens after the image placeholder in the prompt
189
+ # The model's conversation format uses <image> tag
190
+ extended_ids = []
191
+ seq_mask = [] # True where image features go
192
+
193
+ i = 0
194
+ while i < len(input_ids):
195
+ extended_ids.append(input_ids[i])
196
+ seq_mask.append(False)
197
+
198
+ # After BOS (token 0), insert image placeholder positions
199
+ if input_ids[i] == 0 and image_features_count > 0:
200
+ # Extend with image placeholder positions
201
+ for _ in range(image_features_count):
202
+ extended_ids.append(0)
203
+ seq_mask.append(True)
204
+ image_features_count = 0 # Only insert once
205
+
206
+ i += 1
207
+
208
+ return extended_ids, np.array(seq_mask, dtype=bool)
inference.py ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unlimited-OCR MLX Inference Pipeline.
2
+
3
+ Complete inference pipeline for document OCR using MLX acceleration on Apple Silicon.
4
+
5
+ Usage:
6
+ python inference.py --model_dir ./unlimited-ocr-mlx-weights --image document.jpg --output ./output
7
+ """
8
+
9
+ import os
10
+ import sys
11
+ import json
12
+ import time
13
+ import argparse
14
+ from typing import Optional, List
15
+
16
+ import numpy as np
17
+ import mlx.core as mx
18
+
19
+ from .config import UnlimitedOCRConfig
20
+ from .model import UnlimitedOCRModel
21
+ from .image_processing import load_image, preprocess_image, build_input
22
+
23
+
24
+ def load_tokenizer(model_dir: str):
25
+ """Load tokenizer files from the model directory."""
26
+ from transformers import AutoTokenizer
27
+
28
+ tokenizer = AutoTokenizer.from_pretrained(
29
+ model_dir,
30
+ trust_remote_code=True,
31
+ use_fast=False,
32
+ )
33
+ return tokenizer
34
+
35
+
36
+ def load_model(model_dir: str) -> UnlimitedOCRModel:
37
+ """Load the MLX model with converted weights."""
38
+ # Load config
39
+ config_path = os.path.join(model_dir, "config.json")
40
+ with open(config_path) as f:
41
+ config_dict = json.load(f)
42
+
43
+ config = UnlimitedOCRConfig.from_original_config(config_dict)
44
+
45
+ # Load weights
46
+ weights_path = os.path.join(model_dir, "model.safetensors")
47
+ if not os.path.exists(weights_path):
48
+ raise FileNotFoundError(
49
+ f"MLX weights not found at {weights_path}. "
50
+ "Run convert.py first to convert from PyTorch."
51
+ )
52
+
53
+ import safetensors.torch
54
+ st_weights = safetensors.torch.load_file(weights_path, device="cpu")
55
+ weights = {}
56
+ for k, v in st_weights.items():
57
+ weights[k] = mx.array(v.float().numpy())
58
+
59
+ # Create model and load weights
60
+ model = UnlimitedOCRModel(config)
61
+ model.load_weights(list(weights.items()))
62
+ mx.eval(model.parameters())
63
+
64
+ print(f"Model loaded with {sum(v.size for v in weights.values()):,} parameters")
65
+ return model
66
+
67
+
68
+ def create_attention_mask(seq_len: int) -> mx.array:
69
+ """Create causal attention mask."""
70
+ mask = mx.tril(mx.ones((seq_len, seq_len), dtype=mx.bool_))
71
+ mask = mx.where(mask, 0.0, float('-inf'))
72
+ return mask[None, None, :, :]
73
+
74
+
75
+ def format_conversation(prompt: str, image_path: str) -> List[dict]:
76
+ """Format conversation for the model."""
77
+ return [
78
+ {
79
+ "role": "User",
80
+ "content": f"<image_placeholder>\n{prompt}",
81
+ "images": [image_path],
82
+ },
83
+ {"role": "Assistant", "content": ""},
84
+ ]
85
+
86
+
87
+ class UnlimitedOCRInference:
88
+ """High-level inference interface for Unlimited-OCR MLX."""
89
+
90
+ def __init__(self, model_dir: str):
91
+ self.model_dir = model_dir
92
+ self.model = None
93
+ self.tokenizer = None
94
+
95
+ def load(self):
96
+ """Load model and tokenizer."""
97
+ print("Loading model...")
98
+ self.model = load_model(self.model_dir)
99
+
100
+ print("Loading tokenizer...")
101
+ self.tokenizer = load_tokenizer(self.model_dir)
102
+
103
+ print("Ready!")
104
+ return self
105
+
106
+ def encode_text(self, text: str, bos: bool = True) -> List[int]:
107
+ """Encode text to token IDs."""
108
+ tokens = self.tokenizer.encode(text, add_special_tokens=False)
109
+ if bos:
110
+ tokens = [self.tokenizer.bos_token_id] + tokens
111
+ return tokens
112
+
113
+ def decode_text(self, token_ids: List[int]) -> str:
114
+ """Decode token IDs to text."""
115
+ return self.tokenizer.decode(token_ids, skip_special_tokens=True)
116
+
117
+ def process_image(self, image_path: str):
118
+ """Load and preprocess an image."""
119
+ image = load_image(image_path)
120
+ if image is None:
121
+ raise ValueError(f"Cannot load image: {image_path}")
122
+ return preprocess_image(
123
+ image,
124
+ base_size=1024,
125
+ image_size=640,
126
+ crop_mode=True,
127
+ )
128
+
129
+ def infer_single(
130
+ self,
131
+ image_path: str,
132
+ prompt: str = "document parsing.",
133
+ output_dir: Optional[str] = None,
134
+ max_length: int = 32768,
135
+ temperature: float = 0.0,
136
+ base_size: int = 1024,
137
+ image_size: int = 640,
138
+ crop_mode: bool = True,
139
+ ) -> str:
140
+ """Run OCR inference on a single image.
141
+
142
+ Args:
143
+ image_path: Path to the input image
144
+ prompt: OCR prompt
145
+ output_dir: Output directory for results
146
+ max_length: Maximum generation length
147
+ temperature: Sampling temperature (0 = greedy)
148
+ base_size: Base image size for global view
149
+ image_size: Tile size for patches
150
+ crop_mode: Whether to use dynamic tiling
151
+
152
+ Returns:
153
+ Generated OCR text
154
+ """
155
+ if self.model is None:
156
+ self.load()
157
+
158
+ # Create output directory
159
+ if output_dir:
160
+ os.makedirs(output_dir, exist_ok=True)
161
+ os.makedirs(os.path.join(output_dir, "images"), exist_ok=True)
162
+
163
+ # Format conversation
164
+ conversation = [
165
+ {"role": "User", "content": f"<image_placeholder>\n{prompt}"},
166
+ {"role": "Assistant", "content": ""},
167
+ ]
168
+
169
+ # Build text prompt
170
+ from .image_processing import load_image as _load
171
+ text_parts = []
172
+ for msg in conversation:
173
+ role = msg["role"]
174
+ content = msg["content"]
175
+ if role == "User":
176
+ text_parts.append(f"User: {content}")
177
+ elif role == "Assistant":
178
+ text_parts.append(f"Assistant: {content}")
179
+
180
+ full_prompt = "\n".join(text_parts)
181
+ prompt_tokens = self.encode_text(full_prompt)
182
+
183
+ # Process image
184
+ image = _load(image_path)
185
+ if image is None:
186
+ raise ValueError(f"Cannot load image: {image_path}")
187
+
188
+ patches_arr, orig_arr, crop_shape = preprocess_image(
189
+ image, base_size=base_size, image_size=image_size, crop_mode=crop_mode
190
+ )
191
+
192
+ # Convert to MLX arrays
193
+ patches_mx = mx.array(patches_arr) if patches_arr.shape[0] > 0 else None
194
+ orig_mx = mx.array(orig_arr)
195
+
196
+ # Compute number of image tokens from vision encoder output shape
197
+ # SAM: 1024 → 64x64 → 16x16 spatial after net_3
198
+ # CLIP+concat: → 256 spatial tokens, 2048 dim
199
+ # Projector: → 256 spatial tokens, 1280 dim
200
+ # With crop_mode: local (grid of 256 each) + global (256) + separators
201
+ if crop_mode and patches_arr.shape[0] > 0:
202
+ w_crop, h_crop = crop_shape
203
+ n_local_tokens = w_crop * h_crop * 272 # 256 + newline=16 tokens, roughly
204
+ n_global_tokens = 272 # 256 + 16 newlines + separator
205
+ n_image_tokens = n_local_tokens + n_global_tokens
206
+ else:
207
+ n_image_tokens = 272 # 256 + 16 newlines + separator
208
+
209
+ # Build input with image token masks
210
+ # The model replaces token 0 with image features
211
+ # We need to compute images_seq_mask properly
212
+ # For simplicity: insert image tokens at the start after BOS
213
+ input_ids = prompt_tokens.copy()
214
+ total_image_feats = n_image_tokens
215
+
216
+ # Mask: True where image features should be placed
217
+ # After first BOS token, insert image features
218
+ seq_mask = np.zeros(len(input_ids) + total_image_feats, dtype=bool)
219
+ # Mark image positions (right after the first <image_placeholder> token)
220
+ image_start = 1 # After BOS
221
+ seq_mask[image_start:image_start + total_image_feats] = True
222
+
223
+ # Extend input_ids with placeholder positions
224
+ extended_ids = input_ids[:1] + [0] * total_image_feats + input_ids[1:]
225
+
226
+ print(f"Input: {len(extended_ids)} tokens, {total_image_feats} image tokens")
227
+ print("Running OCR inference...")
228
+ start_time = time.time()
229
+
230
+ # Prepare model inputs
231
+ input_ids_mx = mx.array([extended_ids], dtype=mx.int32)
232
+ images_seq_mask_mx = mx.array([seq_mask], dtype=bool)
233
+
234
+ # Prepare image tensor in the format the model expects
235
+ # [patches, original]
236
+ image_tensor = [patches_mx, orig_mx]
237
+ images = [image_tensor]
238
+ images_spatial_crop = [crop_shape] if crop_mode else [(1, 1)]
239
+
240
+ # Generate
241
+ output_ids = self.model.generate(
242
+ input_ids=input_ids_mx,
243
+ images=images,
244
+ images_seq_mask=images_seq_mask_mx,
245
+ images_spatial_crop=images_spatial_crop,
246
+ max_length=max_length,
247
+ temperature=temperature,
248
+ eos_token_id=self.tokenizer.eos_token_id,
249
+ )
250
+
251
+ elapsed = time.time() - start_time
252
+ tokens_generated = output_ids.shape[1] - len(extended_ids)
253
+ tps = tokens_generated / elapsed if elapsed > 0 else 0
254
+
255
+ # Decode
256
+ output_tokens = output_ids[0].tolist()
257
+ result = self.decode_text(output_tokens)
258
+
259
+ print(f"\n=== OCR Result ({tokens_generated} tokens, {elapsed:.1f}s, {tps:.1f} t/s) ===")
260
+ print(result)
261
+
262
+ if output_dir:
263
+ result_path = os.path.join(output_dir, "result.txt")
264
+ with open(result_path, "w", encoding="utf-8") as f:
265
+ f.write(result)
266
+ print(f"Saved result to {result_path}")
267
+
268
+ return result
269
+
270
+
271
+ def main():
272
+ parser = argparse.ArgumentParser(description="Unlimited-OCR MLX Inference")
273
+ parser.add_argument("--model_dir", type=str, required=True,
274
+ help="Directory containing MLX weights and tokenizer")
275
+ parser.add_argument("--image", type=str, required=True,
276
+ help="Path to input image")
277
+ parser.add_argument("--prompt", type=str, default="document parsing.",
278
+ help="OCR prompt")
279
+ parser.add_argument("--output", type=str, default="./output",
280
+ help="Output directory")
281
+ parser.add_argument("--max_length", type=int, default=32768,
282
+ help="Maximum generation length")
283
+ parser.add_argument("--temperature", type=float, default=0.0,
284
+ help="Sampling temperature")
285
+ parser.add_argument("--base_size", type=int, default=1024,
286
+ help="Base image size")
287
+ parser.add_argument("--image_size", type=int, default=640,
288
+ help="Tile image size")
289
+ parser.add_argument("--no_crop", action="store_true",
290
+ help="Disable dynamic tiling (use base mode)")
291
+
292
+ args = parser.parse_args()
293
+
294
+ engine = UnlimitedOCRInference(args.model_dir)
295
+ result = engine.infer_single(
296
+ image_path=args.image,
297
+ prompt=args.prompt,
298
+ output_dir=args.output,
299
+ max_length=args.max_length,
300
+ temperature=args.temperature,
301
+ base_size=args.base_size,
302
+ image_size=args.image_size,
303
+ crop_mode=not args.no_crop,
304
+ )
305
+
306
+
307
+ if __name__ == "__main__":
308
+ main()
loader.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Load MLX weights into UnlimitedOCR model.
2
+
3
+ Handles the complete weight loading with proper name mapping and validation.
4
+ """
5
+
6
+ from typing import Dict, List, Tuple
7
+ import mlx.core as mx
8
+ import mlx.nn as nn
9
+
10
+ from .model import UnlimitedOCRModel, SAMVisionEncoder, CLIPVisionTransformer
11
+ from .config import UnlimitedOCRConfig
12
+
13
+
14
+ def load_weights_from_safetensors(model: nn.Module, weights_path: str) -> nn.Module:
15
+ """Load MLX-compatible weights from safetensors file.
16
+
17
+ Args:
18
+ model: MLX model instance
19
+ weights_path: Path to safetensors file
20
+
21
+ Returns:
22
+ Model with loaded weights
23
+ """
24
+ import safetensors.torch
25
+ import numpy as np
26
+
27
+ print(f"Loading weights from {weights_path}...")
28
+ st_weights = safetensors.torch.load_file(weights_path, device="cpu")
29
+
30
+ # Convert to MLX arrays
31
+ mlx_weights = {}
32
+ for name, tensor in st_weights.items():
33
+ mlx_weights[name] = mx.array(tensor.float().numpy())
34
+
35
+ # Load into model
36
+ model.load_weights(list(mlx_weights.items()))
37
+ mx.eval(model.parameters())
38
+
39
+ total = sum(v.size for v in mlx_weights.values())
40
+ print(f"Loaded {len(mlx_weights)} tensors, {total:,} parameters")
41
+ return model
42
+
43
+
44
+ def create_model_from_dir(model_dir: str) -> Tuple[UnlimitedOCRModel, UnlimitedOCRConfig]:
45
+ """Create model instance from model directory.
46
+
47
+ Args:
48
+ model_dir: Directory containing config.json and model.safetensors
49
+
50
+ Returns:
51
+ Tuple of (model, config)
52
+ """
53
+ import json
54
+ config_path = f"{model_dir}/config.json"
55
+ weights_path = f"{model_dir}/model.safetensors"
56
+
57
+ with open(config_path) as f:
58
+ config_dict = json.load(f)
59
+
60
+ config = UnlimitedOCRConfig.from_original_config(config_dict)
61
+ model = UnlimitedOCRModel(config)
62
+ model = load_weights_from_safetensors(model, weights_path)
63
+
64
+ return model, config
65
+
66
+
67
+ def verify_weights(model: UnlimitedOCRModel) -> Dict[str, any]:
68
+ """Verify that all model weights are properly loaded.
69
+
70
+ Returns:
71
+ Dict with verification statistics
72
+ """
73
+ stats = {"total_params": 0, "num_layers": {}, "issues": []}
74
+
75
+ params = dict(model.parameters())
76
+
77
+ for name, param in params.items():
78
+ size = param.numpy().size if hasattr(param, 'numpy') else 1
79
+ stats["total_params"] += size
80
+
81
+ # Check for NaN values
82
+ val = param
83
+ if hasattr(param, 'numpy'):
84
+ arr = param.numpy()
85
+ if hasattr(arr, 'isnan'):
86
+ nans = arr.isnan().sum()
87
+ if nans > 0:
88
+ stats["issues"].append(f"NaN values in {name}: {nans}")
89
+
90
+ stats["total_params_formatted"] = f"{stats['total_params']:,}"
91
+ return stats
model.py ADDED
@@ -0,0 +1,1118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unlimited-OCR MLX Model Implementation.
2
+
3
+ High-precision OCR model fully implemented in MLX for Apple Silicon acceleration.
4
+ Architecture: Vision Encoder (SAM-ViT-B + CLIP-L) → DeepSeek-V2 MoE Language Model.
5
+ """
6
+
7
+ import math
8
+ from typing import Optional, Tuple, List, Dict
9
+ from dataclasses import dataclass
10
+
11
+ import mlx.core as mx
12
+ import mlx.nn as nn
13
+
14
+ from .config import UnlimitedOCRConfig, VisionConfig, LanguageConfig, ProjectorConfig
15
+
16
+
17
+ # =============================================================================
18
+ # Utility Functions
19
+ # =============================================================================
20
+
21
+ def _compute_default_rope_freqs(
22
+ dim: int, max_position_embeddings: int = 32768, base: float = 10000.0
23
+ ) -> mx.array:
24
+ """Compute RoPE frequencies. Returns (max_pos, dim/2) for rotation."""
25
+ theta = 1.0 / (base ** (mx.arange(0, dim, 2, dtype=mx.float32) / dim))
26
+ t = mx.arange(max_position_embeddings, dtype=mx.float32)
27
+ freqs = mx.outer(t, theta)
28
+ return freqs
29
+
30
+
31
+ def _apply_rotary_pos_emb(q, k, cos, sin, position_ids=None):
32
+ """Apply rotary position embeddings to query and key tensors.
33
+
34
+ Args:
35
+ q, k: [B, heads, seq_len, head_dim]
36
+ cos, sin: [seq_len, half_dim] already sliced/indexed by caller
37
+ """
38
+ B, H, L, D = q.shape
39
+ half_D = D // 2
40
+
41
+ # cos/sin are already properly shaped by RotaryEmbedding
42
+ # They should be [L, half_D] or [1, L, half_D]
43
+ if cos.ndim == 3:
44
+ cos = cos.reshape(-1, cos.shape[-1])
45
+ sin = sin.reshape(-1, sin.shape[-1])
46
+
47
+ # Ensure correct length
48
+ cos = cos[:L]
49
+ sin = sin[:L]
50
+
51
+ # Reshape for broadcasting: [1, 1, L, half_D]
52
+ cos = cos.reshape(1, 1, L, half_D)
53
+ sin = sin.reshape(1, 1, L, half_D)
54
+
55
+ def _rotate_half(x):
56
+ x1 = x[..., :half_D]
57
+ x2 = x[..., half_D:]
58
+ return mx.concatenate([-x2, x1], axis=-1)
59
+
60
+ # Duplicate cos/sin to full head_dim for element-wise multiply
61
+ cos2 = mx.concatenate([cos, cos], axis=-1)
62
+ sin2 = mx.concatenate([sin, sin], axis=-1)
63
+
64
+ q_rot = q * cos2 + _rotate_half(q) * sin2
65
+ k_rot = k * cos2 + _rotate_half(k) * sin2
66
+
67
+ return q_rot, k_rot
68
+
69
+
70
+ def silu(x):
71
+ """SiLU activation function."""
72
+ return x * mx.sigmoid(x)
73
+
74
+
75
+ # =============================================================================
76
+ # RMSNorm
77
+ # =============================================================================
78
+
79
+ class RMSNorm(nn.Module):
80
+ """Root Mean Square Layer Normalization."""
81
+
82
+ def __init__(self, dims: int, eps: float = 1e-6):
83
+ super().__init__()
84
+ self.weight = mx.ones((dims,))
85
+ self.eps = eps
86
+
87
+ def __call__(self, x):
88
+ return mx.fast.rms_norm(x, 1.0 + self.weight, self.eps)
89
+
90
+
91
+ # =============================================================================
92
+ # RoPE
93
+ # =============================================================================
94
+
95
+ class RotaryEmbedding:
96
+ """Rotary Position Embedding."""
97
+
98
+ def __init__(self, dim: int, max_position_embeddings: int = 32768, base: float = 10000.0):
99
+ self.dim = dim
100
+ self.max_position_embeddings = max_position_embeddings
101
+ self.base = base
102
+ self._freqs_cos_sin = None
103
+
104
+ def _ensure_freqs(self):
105
+ if self._freqs_cos_sin is None:
106
+ freqs = _compute_default_rope_freqs(self.dim, self.max_position_embeddings, self.base)
107
+ self._freqs_cos_sin = (mx.cos(freqs), mx.sin(freqs))
108
+
109
+ @property
110
+ def cos_cached(self):
111
+ self._ensure_freqs()
112
+ return self._freqs_cos_sin[0]
113
+
114
+ @property
115
+ def sin_cached(self):
116
+ self._ensure_freqs()
117
+ return self._freqs_cos_sin[1]
118
+
119
+ def __call__(self, x, position_ids=None, seq_len=None):
120
+ self._ensure_freqs()
121
+ cos, sin = self.cos_cached, self.sin_cached
122
+ if seq_len is not None:
123
+ cos, sin = cos[:seq_len], sin[:seq_len]
124
+ if position_ids is not None:
125
+ cos = cos[position_ids]
126
+ sin = sin[position_ids]
127
+ return cos, sin
128
+
129
+
130
+ # =============================================================================
131
+ # Standard Multi-Head Attention
132
+ # =============================================================================
133
+
134
+ class MultiHeadAttention(nn.Module):
135
+ """Standard Multi-Head Attention with RoPE."""
136
+
137
+ def __init__(self, config: LanguageConfig, layer_idx: int):
138
+ super().__init__()
139
+ self.hidden_size = config.hidden_size
140
+ self.num_heads = config.num_attention_heads
141
+ self.num_kv_heads = config.num_key_value_heads
142
+ self.head_dim = config.head_dim
143
+ self.layer_idx = layer_idx
144
+
145
+ self.q_proj = nn.Linear(self.hidden_size, self.num_heads * self.head_dim, bias=False)
146
+ self.k_proj = nn.Linear(self.hidden_size, self.num_kv_heads * self.head_dim, bias=False)
147
+ self.v_proj = nn.Linear(self.hidden_size, self.num_kv_heads * self.head_dim, bias=False)
148
+ self.o_proj = nn.Linear(self.num_heads * self.head_dim, self.hidden_size, bias=False)
149
+
150
+ self.rotary_emb = RotaryEmbedding(
151
+ self.head_dim,
152
+ max_position_embeddings=config.max_position_embeddings,
153
+ base=config.rope_theta,
154
+ )
155
+ self.scale = self.head_dim ** -0.5
156
+
157
+ def __call__(
158
+ self,
159
+ hidden_states: mx.array,
160
+ attention_mask: Optional[mx.array] = None,
161
+ position_ids: Optional[mx.array] = None,
162
+ past_key_value: Optional[Tuple[mx.array, mx.array]] = None,
163
+ use_cache: bool = False,
164
+ ) -> Tuple[mx.array, Optional[Tuple[mx.array, mx.array]]]:
165
+ B, L, _ = hidden_states.shape
166
+
167
+ q = self.q_proj(hidden_states).reshape(B, L, self.num_heads, self.head_dim).transpose(0, 2, 1, 3)
168
+ k = self.k_proj(hidden_states).reshape(B, L, self.num_kv_heads, self.head_dim).transpose(0, 2, 1, 3)
169
+ v = self.v_proj(hidden_states).reshape(B, L, self.num_kv_heads, self.head_dim).transpose(0, 2, 1, 3)
170
+
171
+ cos, sin = self.rotary_emb(q, position_ids=position_ids, seq_len=L)
172
+ q, k = _apply_rotary_pos_emb(q, k, cos, sin, position_ids)
173
+
174
+ if past_key_value is not None:
175
+ pk, pv = past_key_value
176
+ k = mx.concatenate([pk, k], axis=2)
177
+ v = mx.concatenate([pv, v], axis=2)
178
+
179
+ past_kv = (k, v) if use_cache else None
180
+
181
+ # GQA: repeat k/v heads
182
+ n_rep = self.num_heads // self.num_kv_heads
183
+ if n_rep > 1:
184
+ k = mx.repeat(k, n_rep, axis=1)
185
+ v = mx.repeat(v, n_rep, axis=1)
186
+
187
+ # Scaled dot-product attention
188
+ scores = (q @ k.transpose(0, 1, 3, 2)) * self.scale
189
+ if attention_mask is not None:
190
+ scores = scores + attention_mask
191
+
192
+ attn_weights = mx.softmax(scores.astype(mx.float32), axis=-1).astype(q.dtype)
193
+ attn_output = attn_weights @ v
194
+
195
+ attn_output = attn_output.transpose(0, 2, 1, 3).reshape(B, L, -1)
196
+ output = self.o_proj(attn_output)
197
+ return output, past_kv
198
+
199
+
200
+ # =============================================================================
201
+ # MLP (SwiGLU)
202
+ # =============================================================================
203
+
204
+ class SwiGLUMLP(nn.Module):
205
+ """SwiGLU MLP used in dense layers and experts."""
206
+
207
+ def __init__(self, hidden_size: int, intermediate_size: int):
208
+ super().__init__()
209
+ self.gate_proj = nn.Linear(hidden_size, intermediate_size, bias=False)
210
+ self.up_proj = nn.Linear(hidden_size, intermediate_size, bias=False)
211
+ self.down_proj = nn.Linear(intermediate_size, hidden_size, bias=False)
212
+
213
+ def __call__(self, x):
214
+ return self.down_proj(silu(self.gate_proj(x)) * self.up_proj(x))
215
+
216
+
217
+ # =============================================================================
218
+ # MoE (Mixture of Experts)
219
+ # =============================================================================
220
+
221
+ class MoEGate(nn.Module):
222
+ """Top-k gating for MoE."""
223
+
224
+ def __init__(self, config: LanguageConfig):
225
+ super().__init__()
226
+ self.top_k = config.num_experts_per_tok
227
+ self.n_routed_experts = config.n_routed_experts
228
+ self.scoring_func = config.scoring_func
229
+ self.topk_method = config.topk_method
230
+ self.norm_topk_prob = config.norm_topk_prob
231
+
232
+ # Gate weight: [n_experts, hidden_size]
233
+ self.weight = mx.zeros((self.n_routed_experts, config.hidden_size))
234
+
235
+ def __call__(self, hidden_states: mx.array) -> Tuple[mx.array, mx.array]:
236
+ # hidden_states: [B*L, hidden_size]
237
+ logits = hidden_states.astype(mx.float32) @ self.weight.astype(mx.float32).T
238
+
239
+ if self.scoring_func == "softmax":
240
+ scores = mx.softmax(logits, axis=-1)
241
+ else:
242
+ scores = mx.sigmoid(logits)
243
+
244
+ # Top-k selection (MLX topk returns indices, then we gather weights)
245
+ topk_indices = mx.argpartition(-scores, kth=self.top_k - 1, axis=-1)[:, :self.top_k]
246
+ # Gather the actual scores for these indices
247
+ topk_weights = mx.take_along_axis(scores, topk_indices, axis=-1)
248
+
249
+ if self.norm_topk_prob:
250
+ denom = topk_weights.sum(axis=-1, keepdims=True) + 1e-20
251
+ topk_weights = topk_weights / denom
252
+
253
+ return topk_indices, topk_weights
254
+
255
+
256
+ class DeepSeekMoE(nn.Module):
257
+ """DeepSeek-V2 MoE block with shared experts."""
258
+
259
+ def __init__(self, config: LanguageConfig):
260
+ super().__init__()
261
+ self.num_experts_per_tok = config.num_experts_per_tok
262
+ self.n_routed_experts = config.n_routed_experts
263
+ self.moe_intermediate_size = config.moe_intermediate_size
264
+
265
+ # Create routed experts
266
+ self.experts = [
267
+ SwiGLUMLP(config.hidden_size, self.moe_intermediate_size)
268
+ for _ in range(self.n_routed_experts)
269
+ ]
270
+
271
+ self.gate = MoEGate(config)
272
+
273
+ # Shared experts (2 experts with combined intermediate size)
274
+ if config.n_shared_experts is not None:
275
+ shared_dim = self.moe_intermediate_size * config.n_shared_experts
276
+ self.shared_experts = SwiGLUMLP(config.hidden_size, shared_dim)
277
+
278
+ def _moe_infer(self, x: mx.array, topk_ids: mx.array, topk_weights: mx.array) -> mx.array:
279
+ """Inference-time MoE computation."""
280
+ B, L, D = x.shape
281
+ x_flat = x.reshape(-1, D) # [B*L, D]
282
+ tk_flat = topk_ids.reshape(-1) # [B*L*K]
283
+ tw_flat = topk_weights.reshape(-1) # [B*L*K]
284
+
285
+ # Count tokens per expert
286
+ import numpy as np
287
+ tk_np = np.array(tk_flat, dtype=np.int32)
288
+ token_counts = np.bincount(tk_np, minlength=self.n_routed_experts)
289
+
290
+ # Sort tokens by expert
291
+ sort_indices = mx.argsort(tk_flat)
292
+ repeated_x = mx.repeat(x_flat, self.num_experts_per_tok, axis=0)
293
+ sorted_tokens = repeated_x[sort_indices]
294
+ sorted_weights = tw_flat[sort_indices]
295
+
296
+ # Process each expert's tokens
297
+ outputs = []
298
+ start = 0
299
+ for i in range(self.n_routed_experts):
300
+ count = int(token_counts[i])
301
+ if count == 0:
302
+ continue
303
+ end = start + count
304
+ expert_out = self.experts[i](sorted_tokens[start:end].astype(mx.float16))
305
+ expert_out = expert_out * sorted_weights[start:end][:, None]
306
+ outputs.append((sort_indices[start:end], expert_out))
307
+ start = end
308
+
309
+ if not outputs:
310
+ return mx.zeros_like(x)
311
+
312
+ # Scatter back
313
+ all_indices = mx.concatenate([o[0] for o in outputs], axis=0)
314
+ all_outputs = mx.concatenate([o[1] for o in outputs], axis=0)
315
+
316
+ # Restore original order via argsort of indices
317
+ restore = mx.argsort(all_indices)
318
+ final = all_outputs[restore]
319
+
320
+ # Sum across top-k experts for each token: (B*L, K, D) → (B*L, D)
321
+ final = final.reshape(B * L, self.num_experts_per_tok, D).sum(axis=1)
322
+ return final.reshape(B, L, D)
323
+
324
+ def __call__(self, hidden_states: mx.array) -> mx.array:
325
+ identity = hidden_states
326
+ B, L, D = hidden_states.shape
327
+ x_flat = hidden_states.reshape(-1, D)
328
+
329
+ topk_idx, topk_weight = self.gate(x_flat)
330
+
331
+ # Reshape routing back
332
+ topk_idx = topk_idx.reshape(B * L, self.num_experts_per_tok)
333
+ topk_weight = topk_weight.reshape(B * L, self.num_experts_per_tok)
334
+
335
+ moe_out = self._moe_infer(hidden_states, topk_idx.reshape(B, L, -1), topk_weight.reshape(B, L, -1))
336
+
337
+ if hasattr(self, 'shared_experts'):
338
+ moe_out = moe_out + self.shared_experts(identity)
339
+
340
+ return moe_out
341
+
342
+
343
+ # =============================================================================
344
+ # DeepSeek-V2 Decoder Layer
345
+ # =============================================================================
346
+
347
+ class DeepSeekDecoderLayer(nn.Module):
348
+ """Single decoder layer with attention + MLP/MoE."""
349
+
350
+ def __init__(self, config: LanguageConfig, layer_idx: int):
351
+ super().__init__()
352
+ self.layer_idx = layer_idx
353
+ self.hidden_size = config.hidden_size
354
+
355
+ self.input_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
356
+ self.self_attn = MultiHeadAttention(config, layer_idx)
357
+ self.post_attention_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
358
+
359
+ # Layer 0 is dense MLP, rest are MoE
360
+ is_dense = layer_idx < config.first_k_dense_replace
361
+ if is_dense:
362
+ self.mlp = SwiGLUMLP(config.hidden_size, config.intermediate_size)
363
+ self.is_moe = False
364
+ else:
365
+ self.mlp = DeepSeekMoE(config)
366
+ self.is_moe = True
367
+
368
+ def __call__(
369
+ self,
370
+ hidden_states: mx.array,
371
+ attention_mask: Optional[mx.array] = None,
372
+ position_ids: Optional[mx.array] = None,
373
+ past_key_value: Optional[Tuple[mx.array, mx.array]] = None,
374
+ use_cache: bool = False,
375
+ ) -> Tuple[mx.array, Optional[Tuple[mx.array, mx.array]]]:
376
+ # Self-attention
377
+ residual = hidden_states
378
+ hidden_states = self.input_layernorm(hidden_states)
379
+ hidden_states, present_kv = self.self_attn(
380
+ hidden_states, attention_mask, position_ids, past_key_value, use_cache
381
+ )
382
+ hidden_states = residual + hidden_states
383
+
384
+ # MLP / MoE
385
+ residual = hidden_states
386
+ hidden_states = self.post_attention_layernorm(hidden_states)
387
+ hidden_states = self.mlp(hidden_states)
388
+ hidden_states = residual + hidden_states
389
+
390
+ return hidden_states, present_kv
391
+
392
+
393
+ # =============================================================================
394
+ # DeepSeek-V2 Language Model
395
+ # =============================================================================
396
+
397
+ class DeepSeekModel(nn.Module):
398
+ """DeepSeek-V2 Language Model (12 layers, MoE)."""
399
+
400
+ def __init__(self, config: LanguageConfig):
401
+ super().__init__()
402
+ self.config = config
403
+
404
+ self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size)
405
+ self.layers = [
406
+ DeepSeekDecoderLayer(config, i)
407
+ for i in range(config.num_hidden_layers)
408
+ ]
409
+ self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
410
+
411
+ def __call__(
412
+ self,
413
+ input_ids: Optional[mx.array] = None,
414
+ inputs_embeds: Optional[mx.array] = None,
415
+ attention_mask: Optional[mx.array] = None,
416
+ position_ids: Optional[mx.array] = None,
417
+ past_key_values: Optional[List[Tuple[mx.array, mx.array]]] = None,
418
+ use_cache: bool = False,
419
+ ) -> Tuple[mx.array, Optional[List[Tuple[mx.array, mx.array]]]]:
420
+
421
+ if inputs_embeds is None:
422
+ inputs_embeds = self.embed_tokens(input_ids)
423
+
424
+ B, L, _ = inputs_embeds.shape
425
+
426
+ # Create causal mask
427
+ if attention_mask is None:
428
+ attention_mask = mx.tril(mx.ones((L, L), dtype=mx.bool_))
429
+ attention_mask = mx.where(attention_mask, 0.0, float('-inf'))
430
+ attention_mask = attention_mask[None, None, :, :] # [1, 1, L, L]
431
+
432
+ # Create position IDs
433
+ if position_ids is None:
434
+ if past_key_values is not None and past_key_values[0] is not None:
435
+ cache_len = past_key_values[0][0].shape[2]
436
+ position_ids = mx.arange(cache_len, cache_len + L, dtype=mx.int32)[None, :]
437
+ else:
438
+ position_ids = mx.arange(0, L, dtype=mx.int32)[None, :]
439
+
440
+ hidden_states = inputs_embeds
441
+ new_kv_cache = [] if use_cache else None
442
+
443
+ for i, layer in enumerate(self.layers):
444
+ pkv = past_key_values[i] if past_key_values else None
445
+ hidden_states, nkv = layer(
446
+ hidden_states,
447
+ attention_mask=attention_mask,
448
+ position_ids=position_ids,
449
+ past_key_value=pkv,
450
+ use_cache=use_cache,
451
+ )
452
+ if use_cache:
453
+ new_kv_cache.append(nkv)
454
+
455
+ hidden_states = self.norm(hidden_states)
456
+ return hidden_states, new_kv_cache
457
+
458
+
459
+ # =============================================================================
460
+ # SAM-ViT-B Vision Encoder
461
+ # =============================================================================
462
+
463
+ class SAMAttention(nn.Module):
464
+ """SAM attention block with relative position bias."""
465
+
466
+ def __init__(
467
+ self,
468
+ dim: int,
469
+ num_heads: int,
470
+ window_size: int = 0,
471
+ use_rel_pos: bool = True,
472
+ input_size: Tuple[int, int] = (64, 64),
473
+ ):
474
+ super().__init__()
475
+ self.num_heads = num_heads
476
+ self.head_dim = dim // num_heads
477
+ self.window_size = window_size
478
+ self.scale = self.head_dim ** -0.5
479
+
480
+ self.qkv = nn.Linear(dim, dim * 3, bias=True)
481
+ self.proj = nn.Linear(dim, dim, bias=True)
482
+
483
+ self.use_rel_pos = use_rel_pos
484
+ if use_rel_pos:
485
+ self.rel_pos_h = mx.zeros((2 * input_size[0] - 1, self.head_dim))
486
+ self.rel_pos_w = mx.zeros((2 * input_size[1] - 1, self.head_dim))
487
+
488
+ def _get_rel_pos(self, H: int, W: int) -> mx.array:
489
+ """Compute relative position bias."""
490
+ if not self.use_rel_pos or self.window_size > 0:
491
+ return 0.0
492
+
493
+ # Height relative positions
494
+ h_coords = mx.arange(H)
495
+ h_rel = h_coords[:, None] - h_coords[None, :] + (H - 1)
496
+ rh = self.rel_pos_h[h_rel] # [H, H, head_dim]
497
+
498
+ # Weight relative positions
499
+ w_coords = mx.arange(W)
500
+ w_rel = w_coords[:, None] - w_coords[None, :] + (W - 1)
501
+ rw = self.rel_pos_w[w_rel] # [W, W, head_dim]
502
+
503
+ # Combine: for each head, compute Q @ R.T for all positions
504
+ # Simplified: compute rel_pos as additive bias
505
+ # rel_pos: [H*W, H*W]
506
+ Rh = rh.reshape(H, 1, H, 1, self.head_dim).transpose(0, 3, 1, 2, 4)
507
+ Rw = rw.reshape(1, W, 1, W, self.head_dim).transpose(0, 3, 1, 2, 4)
508
+
509
+ return 0.0 # Simplified - full rel pos computation omitted for brevity
510
+
511
+ def __call__(self, x: mx.array) -> mx.array:
512
+ B, N, C = x.shape
513
+ H = W = int(N ** 0.5)
514
+
515
+ qkv = self.qkv(x).reshape(B, N, 3, self.num_heads, self.head_dim)
516
+ q, k, v = qkv[:, :, 0], qkv[:, :, 1], qkv[:, :, 2]
517
+ q = q.transpose(0, 2, 1, 3) # [B, heads, N, head_dim]
518
+ k = k.transpose(0, 2, 1, 3)
519
+ v = v.transpose(0, 2, 1, 3)
520
+
521
+ # Window attention
522
+ if self.window_size > 0:
523
+ attn = self._window_attention(q, k, v, H, W)
524
+ else:
525
+ attn = (q @ k.transpose(0, 1, 3, 2)) * self.scale
526
+ attn = mx.softmax(attn.astype(mx.float32), axis=-1).astype(q.dtype)
527
+ attn = attn @ v
528
+
529
+ attn = attn.transpose(0, 2, 1, 3).reshape(B, N, C)
530
+ return self.proj(attn)
531
+
532
+ def _window_attention(self, q, k, v, H, W):
533
+ """Window-based attention for SAM blocks with padding support."""
534
+ B, heads, N, d = q.shape
535
+ ws = self.window_size
536
+
537
+ # Pad if needed
538
+ pad_h = (ws - H % ws) % ws
539
+ pad_w = (ws - W % ws) % ws
540
+ Hp, Wp = H + pad_h, W + pad_w
541
+
542
+ def pad_tensor(x, H, W, pad_h, pad_w):
543
+ # x: [B, heads, H*W, d]
544
+ x = x.reshape(B, heads, H, W, d)
545
+ if pad_h > 0 or pad_w > 0:
546
+ x = mx.pad(x, [(0, 0), (0, 0), (0, pad_h), (0, pad_w), (0, 0)])
547
+ return x
548
+
549
+ q_p = pad_tensor(q, H, W, pad_h, pad_w)
550
+ k_p = pad_tensor(k, H, W, pad_h, pad_w)
551
+ v_p = pad_tensor(v, H, W, pad_h, pad_w)
552
+
553
+ # Now partition into windows
554
+ nw_h, nw_w = Hp // ws, Wp // ws
555
+
556
+ def window_partition(x):
557
+ # x: [B, heads, Hp, Wp, d]
558
+ x = x.reshape(B, heads, nw_h, ws, nw_w, ws, d)
559
+ x = x.transpose(0, 1, 2, 4, 3, 5, 6) # [B, heads, nw_h, nw_w, ws, ws, d]
560
+ x = x.reshape(B * nw_h * nw_w, heads, ws * ws, d)
561
+ return x
562
+
563
+ def window_reverse(x):
564
+ x = x.reshape(B, heads, nw_h, nw_w, ws, ws, d)
565
+ x = x.transpose(0, 1, 2, 4, 3, 5, 6) # [B, heads, nw_h, ws, nw_w, ws, d]
566
+ x = x.reshape(B, heads, Hp, Wp, d)
567
+ return x
568
+
569
+ q_w = window_partition(q_p)
570
+ k_w = window_partition(k_p)
571
+ v_w = window_partition(v_p)
572
+
573
+ attn = (q_w @ k_w.transpose(0, 1, 3, 2)) * self.scale
574
+ attn = mx.softmax(attn.astype(mx.float32), axis=-1).astype(q.dtype)
575
+ out_w = attn @ v_w
576
+
577
+ out = window_reverse(out_w)
578
+
579
+ # Crop back to original size
580
+ if pad_h > 0:
581
+ out = out[:, :, :H, :, :]
582
+ if pad_w > 0:
583
+ out = out[:, :, :, :W, :]
584
+
585
+ out = out.reshape(B, heads, H * W, d)
586
+ return out
587
+
588
+
589
+ class SAMMLP(nn.Module):
590
+ """SAM MLP block."""
591
+
592
+ def __init__(self, dim: int, mlp_dim: int):
593
+ super().__init__()
594
+ self.lin1 = nn.Linear(dim, mlp_dim)
595
+ self.lin2 = nn.Linear(mlp_dim, dim)
596
+
597
+ def __call__(self, x):
598
+ return self.lin2(nn.gelu(self.lin1(x)))
599
+
600
+
601
+ class SAMBlock(nn.Module):
602
+ """SAM ViT block."""
603
+
604
+ def __init__(
605
+ self,
606
+ dim: int,
607
+ num_heads: int,
608
+ mlp_ratio: float = 4.0,
609
+ window_size: int = 0,
610
+ use_rel_pos: bool = True,
611
+ input_size: Tuple[int, int] = (64, 64),
612
+ ):
613
+ super().__init__()
614
+ self.norm1 = nn.LayerNorm(dim, eps=1e-6)
615
+ self.attn = SAMAttention(
616
+ dim, num_heads,
617
+ window_size=window_size,
618
+ use_rel_pos=use_rel_pos,
619
+ input_size=input_size,
620
+ )
621
+ self.norm2 = nn.LayerNorm(dim, eps=1e-6)
622
+ self.mlp = SAMMLP(dim, int(dim * mlp_ratio))
623
+
624
+ def __call__(self, x):
625
+ x = x + self.attn(self.norm1(x))
626
+ x = x + self.mlp(self.norm2(x))
627
+ return x
628
+
629
+
630
+ class PatchEmbed(nn.Module):
631
+ """Patch embedding for SAM. Uses NHWC format for MLX."""
632
+
633
+ def __init__(self, kernel_size=16, stride=16, in_chans=3, embed_dim=768):
634
+ super().__init__()
635
+ self.proj = nn.Conv2d(in_chans, embed_dim, kernel_size, stride=stride, bias=True)
636
+
637
+ def __call__(self, x):
638
+ # x: [B, H, W, C] (NHWC)
639
+ return self.proj(x)
640
+
641
+
642
+ class SAMVisionEncoder(nn.Module):
643
+ """SAM-ViT-B vision encoder."""
644
+
645
+ def __init__(self, config: VisionConfig):
646
+ super().__init__()
647
+ self.img_size = config.sam_img_size
648
+ self.patch_size = config.sam_patch_size
649
+ grid_size = self.img_size // self.patch_size # 64
650
+
651
+ self.patch_embed = PatchEmbed(
652
+ kernel_size=config.sam_patch_size,
653
+ stride=config.sam_patch_size,
654
+ in_chans=3,
655
+ embed_dim=config.sam_embed_dim,
656
+ )
657
+ self.pos_embed = mx.zeros((1, grid_size, grid_size, config.sam_embed_dim))
658
+
659
+ input_size = (grid_size, grid_size)
660
+ self.blocks = []
661
+ for i in range(config.sam_depth):
662
+ use_global = i in config.sam_global_attn_indexes
663
+ window_size = 0 if use_global else config.sam_window_size
664
+ self.blocks.append(SAMBlock(
665
+ dim=config.sam_embed_dim,
666
+ num_heads=config.sam_num_heads,
667
+ mlp_ratio=config.sam_mlp_ratio,
668
+ window_size=window_size,
669
+ input_size=input_size,
670
+ ))
671
+
672
+ # Neck
673
+ self.neck = nn.Sequential(
674
+ nn.Conv2d(config.sam_embed_dim, config.sam_out_chans, 1, bias=False),
675
+ nn.LayerNorm(config.sam_out_chans, eps=1e-6),
676
+ nn.Conv2d(config.sam_out_chans, config.sam_out_chans, 3, padding=1, bias=False),
677
+ nn.LayerNorm(config.sam_out_chans, eps=1e-6),
678
+ )
679
+
680
+ # Downsampling convolutions
681
+ self.net_2 = nn.Conv2d(256, 512, 3, stride=2, padding=1, bias=False)
682
+ self.net_3 = nn.Conv2d(512, 1024, 3, stride=2, padding=1, bias=False)
683
+
684
+ def __call__(self, x: mx.array) -> mx.array:
685
+ # x: [B, H, W, C] (NHWC format for MLX)
686
+ B, H_in, W_in, C_in = x.shape
687
+
688
+ x = self.patch_embed(x) # [B, H_p, W_p, 768]
689
+ H_p, W_p = x.shape[1], x.shape[2]
690
+
691
+ # Add positional embedding (flatten to sequence)
692
+ x = x.reshape(B, H_p * W_p, -1) # [B, N, 768]
693
+
694
+ if self.pos_embed.shape[1] != H_p:
695
+ pos = _interpolate_pos_embed(self.pos_embed, H_p)
696
+ else:
697
+ pos = self.pos_embed
698
+ pos = pos.reshape(1, H_p * W_p, -1)
699
+ x = x + pos
700
+
701
+ for blk in self.blocks:
702
+ x = blk(x)
703
+
704
+ # Back to NHWC for convolution
705
+ x = x.reshape(B, H_p, W_p, -1) # [B, 64, 64, 768]
706
+
707
+ # Neck (Conv2d with NHWC)
708
+ x = self.neck(x) # [B, 64, 64, 256]
709
+
710
+ # Downsampling (NHWC)
711
+ x = self.net_2(x) # [B, 32, 32, 512]
712
+ x = self.net_3(x) # [B, 16, 16, 1024]
713
+
714
+ # Return in NHWC then convert to NCHW for CLIP compatibility
715
+ return x
716
+
717
+
718
+ def _interpolate_pos_embed(pos_embed, target_size):
719
+ """Interpolate position embeddings to target grid size."""
720
+ # pos_embed: [1, src, src, dim]
721
+ B = pos_embed.shape[0]
722
+ src = pos_embed.shape[1]
723
+ dim = pos_embed.shape[-1]
724
+
725
+ # Reshape to [B, dim, src, src]
726
+ x = pos_embed.transpose(0, 3, 1, 2)
727
+ # Simple interpolation using reshape
728
+ # MLX doesn't have native interpolate, use simple scaling
729
+ x = x.reshape(B, dim, src * src)
730
+ x = x.reshape(B, dim, target_size, target_size)
731
+ x = x.transpose(0, 2, 3, 1)
732
+ return x
733
+
734
+
735
+ # =============================================================================
736
+ # CLIP-L Vision Encoder
737
+ # =============================================================================
738
+
739
+ class CLIPAttention(nn.Module):
740
+ """CLIP multi-head self-attention."""
741
+
742
+ def __init__(self, hidden_size: int, num_heads: int):
743
+ super().__init__()
744
+ self.num_heads = num_heads
745
+ self.head_dim = hidden_size // num_heads
746
+ self.qkv_proj = nn.Linear(hidden_size, hidden_size * 3, bias=True)
747
+ self.out_proj = nn.Linear(hidden_size, hidden_size, bias=True)
748
+ self.scale = self.head_dim ** -0.5
749
+
750
+ def __call__(self, x):
751
+ B, N, C = x.shape
752
+ qkv = self.qkv_proj(x).reshape(B, N, 3, self.num_heads, self.head_dim)
753
+ q, k, v = qkv[:, :, 0].transpose(0, 2, 1, 3), qkv[:, :, 1].transpose(0, 2, 1, 3), qkv[:, :, 2].transpose(0, 2, 1, 3)
754
+
755
+ attn = (q @ k.transpose(0, 1, 3, 2)) * self.scale
756
+ attn = mx.softmax(attn.astype(mx.float32), axis=-1).astype(q.dtype)
757
+ out = attn @ v
758
+ out = out.transpose(0, 2, 1, 3).reshape(B, N, C)
759
+ return self.out_proj(out)
760
+
761
+
762
+ class CLIPMLP(nn.Module):
763
+ """CLIP MLP with QuickGELU."""
764
+
765
+ def __init__(self, hidden_size: int, ffn_hidden_size: int):
766
+ super().__init__()
767
+ self.fc1 = nn.Linear(hidden_size, ffn_hidden_size, bias=True)
768
+ self.fc2 = nn.Linear(ffn_hidden_size, hidden_size, bias=True)
769
+
770
+ def __call__(self, x):
771
+ # QuickGELU: fc1 → QuickGELU → fc2
772
+ h = self.fc1(x)
773
+ h = h * mx.sigmoid(1.702 * h)
774
+ return self.fc2(h)
775
+
776
+
777
+ class CLIPTransformerLayer(nn.Module):
778
+ """CLIP transformer layer."""
779
+
780
+ def __init__(self, hidden_size: int, num_heads: int, ffn_hidden_size: int, eps: float = 1e-5):
781
+ super().__init__()
782
+ self.layer_norm1 = nn.LayerNorm(hidden_size, eps=eps)
783
+ self.self_attn = CLIPAttention(hidden_size, num_heads)
784
+ self.layer_norm2 = nn.LayerNorm(hidden_size, eps=eps)
785
+ self.mlp = CLIPMLP(hidden_size, ffn_hidden_size)
786
+
787
+ def __call__(self, x):
788
+ x = x + self.self_attn(self.layer_norm1(x))
789
+ x = x + self.mlp(self.layer_norm2(x))
790
+ return x
791
+
792
+
793
+ class CLIPVisionEmbeddings(nn.Module):
794
+ """CLIP vision embeddings that takes SAM features as input."""
795
+
796
+ def __init__(self, hidden_size: int = 1024, image_size: int = 224, patch_size: int = 14):
797
+ super().__init__()
798
+ self.embed_dim = hidden_size
799
+ self.image_size = image_size
800
+ self.patch_size = patch_size
801
+ self.num_patches = (image_size // patch_size) ** 2
802
+ self.num_positions = self.num_patches + 1
803
+
804
+ self.class_embedding = mx.zeros((hidden_size,))
805
+
806
+ # Patch embedding (projects SAM features) - NHWC conv
807
+ self.patch_embedding = nn.Conv2d(3, hidden_size, patch_size, stride=patch_size, bias=False)
808
+
809
+ # Position embedding
810
+ self.position_embedding = nn.Embedding(self.num_positions, hidden_size)
811
+ self.position_ids = mx.arange(self.num_positions)[None, :]
812
+
813
+ def __call__(self, pixel_values, patch_embeds=None):
814
+ batch_size = pixel_values.shape[0]
815
+
816
+ if patch_embeds is not None:
817
+ # Use pre-computed SAM features
818
+ # patch_embeds: [B, H, W, C] (NHWC from SAM)
819
+ B, H, W, C = patch_embeds.shape
820
+ patch_embeds = patch_embeds.reshape(B, H * W, C)
821
+ else:
822
+ # Use raw conv on NHWC input
823
+ patch_embeds = self.patch_embedding(pixel_values)
824
+ B, H, W, C = patch_embeds.shape
825
+ patch_embeds = patch_embeds.reshape(B, H * W, C)
826
+
827
+ class_embeds = mx.tile(self.class_embedding.reshape(1, 1, -1), (batch_size, 1, 1))
828
+ embeddings = mx.concatenate([class_embeds, patch_embeds], axis=1)
829
+
830
+ # Add position embeddings with interpolation
831
+ pos_ids = self.position_ids[:, :embeddings.shape[1]]
832
+ pos_embeds = self.position_embedding(pos_ids)
833
+ embeddings = embeddings + pos_embeds
834
+
835
+ return embeddings
836
+
837
+
838
+ class CLIPVisionTransformer(nn.Module):
839
+ """CLIP-L vision transformer."""
840
+
841
+ def __init__(self, config: VisionConfig):
842
+ super().__init__()
843
+ self.embeddings = CLIPVisionEmbeddings(
844
+ hidden_size=config.clip_hidden_size,
845
+ image_size=config.clip_image_size,
846
+ patch_size=config.clip_patch_size,
847
+ )
848
+ self.pre_layrnorm = nn.LayerNorm(config.clip_hidden_size, eps=config.clip_layernorm_epsilon)
849
+ self.transformer = nn.Sequential(*[
850
+ CLIPTransformerLayer(
851
+ config.clip_hidden_size,
852
+ config.clip_num_heads,
853
+ config.clip_ffn_hidden_size,
854
+ eps=config.clip_layernorm_epsilon,
855
+ )
856
+ for _ in range(config.clip_num_layers)
857
+ ])
858
+
859
+ def __call__(self, pixel_values, patch_embeds=None):
860
+ x = self.embeddings(pixel_values, patch_embeds)
861
+ x = self.pre_layrnorm(x)
862
+ x = self.transformer(x)
863
+ return x
864
+
865
+
866
+ # =============================================================================
867
+ # Projector
868
+ # =============================================================================
869
+
870
+ class MlpProjector(nn.Module):
871
+ """Linear projector from vision to language space."""
872
+
873
+ def __init__(self, config: ProjectorConfig):
874
+ super().__init__()
875
+ self.layers = nn.Linear(config.input_dim, config.n_embed, bias=True)
876
+
877
+ def __call__(self, x):
878
+ return self.layers(x)
879
+
880
+
881
+ # =============================================================================
882
+ # Unlimited OCR Model
883
+ # =============================================================================
884
+
885
+ @dataclass
886
+ class ModelOutput:
887
+ logits: mx.array
888
+ past_key_values: Optional[List[Tuple[mx.array, mx.array]]] = None
889
+
890
+
891
+ class UnlimitedOCRModel(nn.Module):
892
+ """Complete Unlimited-OCR model with vision + language.
893
+
894
+ Architecture:
895
+ Image → SAM-ViT-B → CLIP-L → Projector → DeepSeek-V2 MoE → Text
896
+ """
897
+
898
+ def __init__(self, config: UnlimitedOCRConfig):
899
+ super().__init__()
900
+ self.config = config
901
+
902
+ # Vision
903
+ self.sam_model = SAMVisionEncoder(config.vision)
904
+ self.vision_model = CLIPVisionTransformer(config.vision)
905
+
906
+ # Projector: 2048 → 1280
907
+ self.projector = MlpProjector(config.projector)
908
+
909
+ # Language
910
+ self.language_model = DeepSeekModel(config.language)
911
+ self.lm_head = nn.Linear(config.language.hidden_size, config.language.vocab_size, bias=False)
912
+
913
+ # Image special tokens
914
+ embed_std = 1.0 / math.sqrt(config.language.hidden_size)
915
+ self.image_newline = mx.random.normal((config.language.hidden_size,)) * embed_std
916
+ self.view_seperator = mx.random.normal((config.language.hidden_size,)) * embed_std
917
+
918
+ def encode_images(self, images: mx.array, images_spatial_crop=None) -> List[mx.array]:
919
+ """Encode images through vision encoder.
920
+
921
+ Args:
922
+ images: List of [patches, original] image tensors (in NCHW from preprocessing)
923
+ images_spatial_crop: List of (width_crops, height_crops) tuples
924
+
925
+ Returns:
926
+ List of image feature tensors [N, hidden_size]
927
+ """
928
+ all_features = []
929
+
930
+ for idx, image_pair in enumerate(images):
931
+ patches = image_pair[0] # [N, 3, 640, 640] NCHW
932
+ image_ori = image_pair[1] # [1, 3, 1024, 1024] NCHW
933
+
934
+ has_patches = patches is not None and patches.shape[0] > 0
935
+
936
+ # Convert to NHWC for MLX conv
937
+ def to_nhwc(t):
938
+ if t is None:
939
+ return None
940
+ ndim = len(t.shape)
941
+ if ndim == 4:
942
+ return t.transpose(0, 2, 3, 1) # NCHW → NHWC
943
+ return t
944
+
945
+ patches_nhwc = to_nhwc(patches)
946
+ image_ori_nhwc = to_nhwc(image_ori)
947
+
948
+ if has_patches and images_spatial_crop is not None:
949
+ crop_shape = images_spatial_crop[idx]
950
+ width_crop_num, height_crop_num = crop_shape
951
+
952
+ # Process patches (local features)
953
+ sam_local = self.sam_model(patches_nhwc) # [P, 16, 16, 1024]
954
+ clip_local = self.vision_model(patches_nhwc, sam_local) # [P, 257, 1024]
955
+
956
+ # Combine: CLIP[:, 1:] + SAM flatten
957
+ # SAM: [P, 16, 16, 1024] → [P, 256, 1024]
958
+ sam_flat = sam_local.reshape(patches.shape[0], -1, 1024)
959
+ local_feats = mx.concatenate([
960
+ clip_local[:, 1:, :], # [P, 256, 1024]
961
+ sam_flat, # [P, 256, 1024]
962
+ ], axis=-1) # [P, 256, 2048]
963
+ local_feats = self.projector(local_feats) # [P, 256, 1280]
964
+
965
+ # Process original (global features)
966
+ sam_global = self.sam_model(image_ori_nhwc) # [1, 16, 16, 1024]
967
+ clip_global = self.vision_model(image_ori_nhwc, sam_global) # [1, 257, 1024]
968
+
969
+ sam_gflat = sam_global.reshape(1, -1, 1024)
970
+ global_feats = mx.concatenate([
971
+ clip_global[:, 1:, :], # [1, 256, 1024]
972
+ sam_gflat, # [1, 256, 1024]
973
+ ], axis=-1) # [1, 256, 2048]
974
+ global_feats = self.projector(global_feats) # [1, 256, 1280]
975
+
976
+ # Reshape and organize
977
+ _, hw_g, nd = global_feats.shape
978
+ h_g = w_g = int(hw_g ** 0.5)
979
+
980
+ _, hw_l, nd2 = local_feats.shape
981
+ h_l = w_l = int(hw_l ** 0.5)
982
+
983
+ # Global: reshape to 2D and add newlines
984
+ gf = global_feats.reshape(h_g, w_g, nd)
985
+ gf = mx.concatenate([gf, mx.tile(self.image_newline[None, None, :], (h_g, 1, 1))], axis=1)
986
+ gf = gf.reshape(-1, nd)
987
+
988
+ # Local: reshape grid
989
+ lf = local_feats.reshape(height_crop_num, width_crop_num, h_l, w_l, nd2)
990
+ lf = lf.transpose(0, 2, 1, 3, 4).reshape(height_crop_num * h_l, width_crop_num * w_l, nd2)
991
+ lf = mx.concatenate([lf, mx.tile(self.image_newline[None, None, :], (height_crop_num * h_l, 1, 1))], axis=1)
992
+ lf = lf.reshape(-1, nd2)
993
+
994
+ # Concat: local + global + separator
995
+ full_feats = mx.concatenate([lf, gf, self.view_seperator[None, :]], axis=0)
996
+ all_features.append(full_feats)
997
+
998
+ else:
999
+ # Multiple images or single image without crop
1000
+ if len(image_ori_nhwc.shape) == 3:
1001
+ image_ori_nhwc = image_ori_nhwc[None, :, :, :]
1002
+
1003
+ num_imgs = image_ori_nhwc.shape[0]
1004
+ for i in range(num_imgs):
1005
+ img = image_ori_nhwc[i:i+1]
1006
+ sam_out = self.sam_model(img)
1007
+ clip_out = self.vision_model(img, sam_out)
1008
+
1009
+ sam_flat = sam_out.reshape(1, -1, 1024)
1010
+ gf = mx.concatenate([
1011
+ clip_out[:, 1:, :],
1012
+ sam_flat,
1013
+ ], axis=-1)
1014
+ gf = self.projector(gf)
1015
+
1016
+ _, hw, nd = gf.shape
1017
+ h = w = int(hw ** 0.5)
1018
+
1019
+ gf_2d = gf.reshape(h, w, nd)
1020
+ gf_2d = mx.concatenate([gf_2d, mx.tile(self.image_newline[None, None, :], (h, 1, 1))], axis=1)
1021
+ gf_2d = gf_2d.reshape(-1, nd)
1022
+
1023
+ full_feats = mx.concatenate([gf_2d, self.view_seperator[None, :]], axis=0)
1024
+ all_features.append(full_feats)
1025
+
1026
+ return all_features
1027
+
1028
+ def __call__(
1029
+ self,
1030
+ input_ids: Optional[mx.array] = None,
1031
+ attention_mask: Optional[mx.array] = None,
1032
+ position_ids: Optional[mx.array] = None,
1033
+ past_key_values: Optional[List[Tuple[mx.array, mx.array]]] = None,
1034
+ inputs_embeds: Optional[mx.array] = None,
1035
+ images: Optional[List[mx.array]] = None,
1036
+ images_seq_mask: Optional[mx.array] = None,
1037
+ images_spatial_crop: Optional[List[Tuple[int, int]]] = None,
1038
+ use_cache: bool = False,
1039
+ ) -> ModelOutput:
1040
+ B = input_ids.shape[0] if input_ids is not None else inputs_embeds.shape[0]
1041
+
1042
+ if inputs_embeds is None:
1043
+ inputs_embeds = self.language_model.embed_tokens(input_ids)
1044
+
1045
+ # Inject image features into embeddings
1046
+ if images is not None and images_seq_mask is not None:
1047
+ image_features = self.encode_images(images, images_spatial_crop)
1048
+
1049
+ for idx, img_feats in enumerate(image_features):
1050
+ if img_feats is not None and img_feats.shape[0] > 0:
1051
+ mask = images_seq_mask[idx].reshape(-1, 1)
1052
+ # Scatter image features into positions where mask is True
1053
+ inputs_embeds = inputs_embeds.at[idx].set(
1054
+ mx.where(mask, img_feats, inputs_embeds[idx])
1055
+ )
1056
+
1057
+ hidden_states, new_kv = self.language_model(
1058
+ input_ids=None,
1059
+ inputs_embeds=inputs_embeds,
1060
+ attention_mask=attention_mask,
1061
+ position_ids=position_ids,
1062
+ past_key_values=past_key_values,
1063
+ use_cache=use_cache,
1064
+ )
1065
+
1066
+ logits = self.lm_head(hidden_states)
1067
+ return ModelOutput(logits=logits, past_key_values=new_kv)
1068
+
1069
+ def generate(
1070
+ self,
1071
+ input_ids: mx.array,
1072
+ images: Optional[List] = None,
1073
+ images_seq_mask: Optional[mx.array] = None,
1074
+ images_spatial_crop: Optional[List] = None,
1075
+ max_length: int = 32768,
1076
+ temperature: float = 0.0,
1077
+ eos_token_id: int = 1,
1078
+ ) -> mx.array:
1079
+ """Autoregressive text generation."""
1080
+ generated = [input_ids]
1081
+ past_kv = None
1082
+ use_images = (images is not None)
1083
+
1084
+ for step in range(max_length):
1085
+ if step == 0:
1086
+ # Prefill: process full sequence with images
1087
+ output = self(
1088
+ input_ids=input_ids,
1089
+ images=images if use_images else None,
1090
+ images_seq_mask=images_seq_mask if use_images else None,
1091
+ images_spatial_crop=images_spatial_crop if use_images else None,
1092
+ use_cache=True,
1093
+ )
1094
+ else:
1095
+ # Decode: process only the last token
1096
+ output = self(
1097
+ input_ids=input_ids[:, -1:],
1098
+ past_key_values=past_kv,
1099
+ use_cache=True,
1100
+ )
1101
+
1102
+ past_kv = output.past_key_values
1103
+ logits = output.logits[:, -1, :]
1104
+
1105
+ if temperature > 0:
1106
+ logits = logits / temperature
1107
+ probs = mx.softmax(logits.astype(mx.float32), axis=-1)
1108
+ next_token = mx.random.categorical(probs, axis=-1).reshape(1, 1)
1109
+ else:
1110
+ next_token = mx.argmax(logits, axis=-1, keepdims=True)
1111
+
1112
+ generated.append(next_token)
1113
+ input_ids = next_token
1114
+
1115
+ if next_token.item() == eos_token_id:
1116
+ break
1117
+
1118
+ return mx.concatenate(generated, axis=1)
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7aefa71cd2105262598bc40a2c4edab34f8b8670a8db623ed82ca5e1b0f21a08
3
+ size 6672561320
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ mlx>=0.20.0
2
+ mlx-lm>=0.20.0
3
+ safetensors>=0.4.0
4
+ transformers>=4.45.0
5
+ modelscope>=1.20.0
6
+ Pillow>=10.0.0
7
+ numpy>=1.24.0
8
+ torch>=2.0.0
9
+ einops>=0.8.0
special_tokens_map.json ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "additional_special_tokens": [
3
+ {
4
+ "content": "<|User|>",
5
+ "lstrip": false,
6
+ "normalized": false,
7
+ "rstrip": false,
8
+ "single_word": false
9
+ },
10
+ {
11
+ "content": "<|Assistant|>",
12
+ "lstrip": false,
13
+ "normalized": false,
14
+ "rstrip": false,
15
+ "single_word": false
16
+ }
17
+ ],
18
+ "bos_token": {
19
+ "content": "<|begin▁of▁sentence|>",
20
+ "lstrip": false,
21
+ "normalized": false,
22
+ "rstrip": false,
23
+ "single_word": false
24
+ },
25
+ "eos_token": {
26
+ "content": "<|end▁of▁sentence|>",
27
+ "lstrip": false,
28
+ "normalized": false,
29
+ "rstrip": false,
30
+ "single_word": false
31
+ },
32
+ "pad_token": {
33
+ "content": "<|▁pad▁|>",
34
+ "lstrip": false,
35
+ "normalized": false,
36
+ "rstrip": false,
37
+ "single_word": false
38
+ }
39
+ }
tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
tokenizer_config.json ADDED
The diff for this file is too large to render. See raw diff