File size: 11,760 Bytes
c33608b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
"""Image processor for Phillnet Mini Text-Vision.

The processor produces Qwen-compatible visual patch tensors for the retained
transplanted vision encoder and deliberately exposes no image or video synthesis
functionality.
"""
from __future__ import annotations

import json
from pathlib import Path
from typing import Any, Sequence

import torch
from PIL import Image

from transformers import AutoTokenizer

try:
    from transformers.feature_extraction_utils import BatchFeature
except Exception:  # pragma: no cover
    BatchFeature = dict  # type: ignore[misc,assignment]


IMAGE_MARKER = "\ue000"


class DendroVisionProcessor:
    """Prepare text and still-image inputs for Phillnet Mini Text-Vision.

    This class supports text generation and image understanding only.  It accepts
    one conversation at a time, creates static two-frame visual patches required
    by the retained vision tower, and expands one image placeholder per merged
    visual token.
    """

    model_input_names = [
        "input_ids",
        "attention_mask",
        "pixel_values",
        "image_grid_thw",
        "mm_token_type_ids",
    ]

    @classmethod
    def register_for_auto_class(cls, auto_class: str = "AutoProcessor") -> None:
        """Compatibility hook used by Transformers dynamic-module loading."""
        cls._auto_class = str(auto_class)

    def __init__(
        self,
        tokenizer: Any,
        *,
        image_token_id: int,
        vision_start_token_id: int,
        vision_end_token_id: int,
        patch_size: int = 16,
        temporal_patch_size: int = 2,
        spatial_merge_size: int = 2,
        image_mean: Sequence[float] = (0.5, 0.5, 0.5),
        image_std: Sequence[float] = (0.5, 0.5, 0.5),
        max_side: int = 448,
    ) -> None:
        self.tokenizer = tokenizer
        self.image_token_id = int(image_token_id)
        self.vision_start_token_id = int(vision_start_token_id)
        self.vision_end_token_id = int(vision_end_token_id)
        self.patch_size = int(patch_size)
        self.temporal_patch_size = int(temporal_patch_size)
        self.spatial_merge_size = int(spatial_merge_size)
        self.image_mean = tuple(float(x) for x in image_mean)
        self.image_std = tuple(float(x) for x in image_std)
        self.max_side = int(max_side)
        if self.patch_size < 1 or self.temporal_patch_size < 1 or self.spatial_merge_size < 1:
            raise ValueError("Visual patch and merge dimensions must be positive")

    @classmethod
    def from_pretrained(cls, pretrained_model_name_or_path: str | Path, **kwargs: Any) -> "DendroVisionProcessor":
        root = Path(pretrained_model_name_or_path)
        config = json.loads((root / "config.json").read_text(encoding="utf-8"))
        preprocessing = json.loads((root / "preprocessor_config.json").read_text(encoding="utf-8"))
        tokenizer_kwargs = dict(kwargs)
        max_side = int(tokenizer_kwargs.pop("max_side", preprocessing.get("max_side", 448)))
        tokenizer_kwargs.pop("trust_remote_code", None)
        tokenizer_kwargs.pop("_from_auto", None)
        tokenizer = AutoTokenizer.from_pretrained(str(root), trust_remote_code=False, **tokenizer_kwargs)
        return cls(
            tokenizer,
            image_token_id=int(config["image_token_id"]),
            vision_start_token_id=int(config["vision_start_token_id"]),
            vision_end_token_id=int(config["vision_end_token_id"]),
            patch_size=int(preprocessing.get("patch_size", 16)),
            temporal_patch_size=int(preprocessing.get("temporal_patch_size", 2)),
            spatial_merge_size=int(preprocessing.get("merge_size", 2)),
            image_mean=preprocessing.get("image_mean", (0.5, 0.5, 0.5)),
            image_std=preprocessing.get("image_std", (0.5, 0.5, 0.5)),
            max_side=max_side,
        )

    def _encode_text(self, value: str) -> list[int]:
        return list(self.tokenizer.encode(str(value), add_special_tokens=False))

    @staticmethod
    def _to_pil(image: Any) -> Image.Image:
        if isinstance(image, Image.Image):
            return image.convert("RGB")
        if isinstance(image, (str, Path)):
            with Image.open(image) as opened:
                return opened.convert("RGB")
        if torch.is_tensor(image):
            value = image.detach().cpu().float()
            if value.ndim == 4 and value.shape[0] == 1:
                value = value[0]
            if value.ndim != 3:
                raise TypeError("Image tensor must be [C,H,W] or [1,C,H,W]")
            if value.shape[0] in {1, 3, 4}:
                value = value[:3].permute(1, 2, 0)
            value = value.clamp(0, 1).mul(255).byte().numpy()
            return Image.fromarray(value).convert("RGB")
        try:
            import numpy as np
            value = np.asarray(image)
            if value.ndim == 3:
                if value.dtype != np.uint8:
                    value = (value.clip(0, 1) * 255 if float(value.max()) <= 1 else value.clip(0, 255)).astype(np.uint8)
                return Image.fromarray(value).convert("RGB")
        except Exception as error:  # pragma: no cover
            raise TypeError("Unsupported image input") from error
        raise TypeError("Unsupported image input")

    def _resize(self, image: Image.Image) -> Image.Image:
        unit = self.patch_size * self.spatial_merge_size
        width, height = image.size
        if max(width, height) > self.max_side:
            scale = self.max_side / max(width, height)
            width, height = round(width * scale), round(height * scale)
        width = max(unit, round(width / unit) * unit)
        height = max(unit, round(height / unit) * unit)
        return image.resize((width, height), Image.Resampling.BICUBIC)

    def _patchify(self, image: Any) -> tuple[torch.Tensor, torch.Tensor, int]:
        prepared = self._resize(self._to_pil(image))
        try:
            import numpy as np
            values = torch.from_numpy(np.asarray(prepared).copy()).permute(2, 0, 1).float().div_(255.0)
        except Exception as error:  # pragma: no cover
            raise RuntimeError("NumPy is required for image preprocessing") from error
        mean = torch.tensor(self.image_mean).view(3, 1, 1)
        std = torch.tensor(self.image_std).view(3, 1, 1)
        values = (values - mean) / std
        channels, height, width = values.shape
        patch = self.patch_size
        temporal = self.temporal_patch_size
        grid_h, grid_w = height // patch, width // patch
        frames = values.unsqueeze(0).repeat(temporal, 1, 1, 1)
        blocks = frames.reshape(1, temporal, channels, grid_h, patch, grid_w, patch)
        blocks = blocks.permute(0, 3, 5, 2, 1, 4, 6).reshape(-1, channels * temporal * patch * patch)
        grid = torch.tensor([[1, grid_h, grid_w]], dtype=torch.long)
        placeholder_count = grid_h // self.spatial_merge_size * (grid_w // self.spatial_merge_size)
        return blocks, grid, int(placeholder_count)

    def _build_single(self, text: str, images: Sequence[Any] | None) -> dict[str, torch.Tensor]:
        image_list = list(images or [])
        if IMAGE_MARKER not in text and image_list:
            text = text + IMAGE_MARKER * len(image_list)
        chunks = text.split(IMAGE_MARKER)
        if len(chunks) != len(image_list) + 1:
            raise ValueError("Image markers must match the number of supplied images")
        ids: list[int] = []
        types: list[int] = []
        pixel_blocks: list[torch.Tensor] = []
        grids: list[torch.Tensor] = []
        for index, chunk in enumerate(chunks):
            text_ids = self._encode_text(chunk)
            ids.extend(text_ids)
            types.extend([0] * len(text_ids))
            if index == len(image_list):
                continue
            blocks, grid, count = self._patchify(image_list[index])
            ids.append(self.vision_start_token_id)
            types.append(0)
            ids.extend([self.image_token_id] * count)
            types.extend([1] * count)
            ids.append(self.vision_end_token_id)
            types.append(0)
            pixel_blocks.append(blocks)
            grids.append(grid)
        output: dict[str, torch.Tensor] = {
            "input_ids": torch.tensor([ids], dtype=torch.long),
            "attention_mask": torch.ones((1, len(ids)), dtype=torch.long),
        }
        if pixel_blocks:
            output["pixel_values"] = torch.cat(pixel_blocks, dim=0)
            output["image_grid_thw"] = torch.cat(grids, dim=0)
            output["mm_token_type_ids"] = torch.tensor([types], dtype=torch.long)
        return output

    def __call__(
        self,
        text: str | Sequence[str],
        *,
        images: Any | Sequence[Any] | None = None,
        return_tensors: str | None = "pt",
        **_: Any,
    ) -> Any:
        if isinstance(text, Sequence) and not isinstance(text, str):
            if len(text) != 1:
                raise ValueError("DendroVisionProcessor currently accepts a single conversation per call")
            text = text[0]
        if images is None:
            image_list: list[Any] = []
        elif isinstance(images, (str, Path, Image.Image)) or torch.is_tensor(images):
            image_list = [images]
        else:
            image_list = list(images)
        encoded = self._build_single(str(text), image_list)
        if return_tensors not in {None, "pt"}:
            raise ValueError("Only return_tensors='pt' is supported")
        return BatchFeature(data=encoded, tensor_type="pt") if BatchFeature is not dict else encoded

    def apply_chat_template(
        self,
        messages: Sequence[dict[str, Any]],
        *,
        tokenize: bool = True,
        add_generation_prompt: bool = True,
        enable_thinking: bool = False,
        return_dict: bool = True,
        return_tensors: str | None = "pt",
        **_: Any,
    ) -> Any:
        parts: list[str] = []
        images: list[Any] = []
        for message in messages:
            role = str(message.get("role", "user"))
            parts.append(f"<|im_start|>{role}\n")
            content = message.get("content", "")
            if isinstance(content, str):
                parts.append(content)
            else:
                for item in content:
                    item_type = item.get("type") if isinstance(item, dict) else None
                    if item_type == "text":
                        parts.append(str(item.get("text", "")))
                    elif item_type == "image":
                        image = item.get("image", item.get("image_url"))
                        if image is None:
                            raise ValueError("Image content must provide an image object")
                        images.append(image)
                        parts.append(IMAGE_MARKER)
                    else:
                        raise ValueError(f"Unsupported chat content type: {item_type!r}")
            parts.append("<|im_end|>\n")
        if add_generation_prompt:
            parts.append("<|im_start|>assistant\n")
            parts.append("<think>\n" if enable_thinking else "<think>\n\n</think>\n\n")
        prompt = "".join(parts)
        if not tokenize:
            return prompt
        encoded = self(prompt, images=images, return_tensors=return_tensors)
        return encoded if return_dict else encoded["input_ids"]

    def save_pretrained(self, save_directory: str | Path, **_: Any) -> tuple[str]:
        root = Path(save_directory)
        root.mkdir(parents=True, exist_ok=True)
        path = root / "preprocessor_config.json"
        path.write_text(json.dumps({"processor_class": "DendroVisionProcessor"}, indent=2) + "\n", encoding="utf-8")
        return (str(path),)