File size: 21,128 Bytes
4f08932
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0f07958
 
 
 
4f08932
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
# coding=utf-8
# Copyright 2026 The ConCor-1 authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Processor for ConCor-1.

Turns an image-text pair into the flat multimodal sequence ConCor-1 expects
(image tokens, text tokens, bridge tokens) and turns the model's per-bridge
logits back into a set of image-text correspondences: a character-level text
span set and a binary image mask per correspondence.
"""

from __future__ import annotations

import string
from typing import Dict, List, Optional, Sequence, Tuple, Union

import numpy as np
import torch
import torch.nn.functional as F

from transformers.feature_extraction_utils import BatchFeature
from transformers.processing_utils import ProcessorMixin

ImageInput = Union["PIL.Image.Image", Sequence["PIL.Image.Image"]]  # noqa: F821

_TRIM_CHARACTERS = frozenset(string.punctuation)


def compute_span_iou(spans_a: List[List[int]], spans_b: List[List[int]]) -> float:
    """IoU of two character-span sets, treated as 1-D binary masks over the text."""
    characters_a = {index for start, end in spans_a for index in range(start, end)}
    characters_b = {index for start, end in spans_b for index in range(start, end)}
    union = len(characters_a | characters_b)
    if union == 0:
        return 0.0
    return len(characters_a & characters_b) / union


def compute_mask_iou(mask_a: np.ndarray, mask_b: np.ndarray) -> float:
    """IoU of two binary masks."""
    flat_a = mask_a.astype(bool).ravel()
    flat_b = mask_b.astype(bool).ravel()
    union = np.count_nonzero(flat_a | flat_b)
    if union == 0:
        return 0.0
    return np.count_nonzero(flat_a & flat_b) / union


class ConCor1Processor(ProcessorMixin):
    r"""Constructs a ConCor-1 processor from a Qwen3.5 image processor and tokenizer.

    Args:
        image_processor: the Qwen3.5 (`Qwen2VLImageProcessorFast`) image processor.
        tokenizer: the Qwen3.5 tokenizer.
        num_bridge_tokens (`int`, *optional*, defaults to 385):
            Number of bridge tokens appended to every sequence.
        bridge_token_id_start (`int`, *optional*, defaults to 248077):
            Token id of the first bridge token.
        patch_size (`int`, *optional*, defaults to 16): vision patch size.
        merge_size (`int`, *optional*, defaults to 2): patch-merger factor.
        min_pixels (`int`, *optional*, defaults to 1003520):
            `min_pixels` forwarded to the image processor.
        max_pixels (`int`, *optional*, defaults to 1003520):
            `max_pixels` forwarded to the image processor. The released
            checkpoint was trained with `min_pixels == max_pixels`, i.e. a fixed
            ~1.0 M pixel budget.
        presence_threshold (`float`, *optional*, defaults to 0.1)
        text_threshold (`float`, *optional*, defaults to 0.45)
        image_threshold (`float`, *optional*, defaults to 0.45)
        nms_iou_threshold (`float`, *optional*, defaults to 0.5)

    Example:

    ```python
    >>> text = "A close-up of a brown bear sitting in lush green grass."
    >>> inputs = processor(images=image, text=text, return_tensors="pt")
    >>> outputs = model(**inputs)
    >>> correspondences = processor.post_process_correspondences(
    ...     outputs, text=text, target_sizes=[(image.height, image.width)]
    ... )
    ```
    """

    attributes = ["image_processor", "tokenizer"]
    # Qwen3.5's image processor, named concretely: transformers 5.x deprecates
    # `"AutoImageProcessor"` here, and its newer releases reject the (slow, fast)
    # tuple form. Requires torchvision, as all fast image processors do.
    image_processor_class = "Qwen2VLImageProcessorFast"
    tokenizer_class = "AutoTokenizer"

    def __init__(
        self,
        image_processor=None,
        tokenizer=None,
        num_bridge_tokens: int = 385,
        bridge_token_id_start: int = 248077,
        patch_size: int = 16,
        merge_size: int = 2,
        num_mask_upsample_blocks: int = 2,
        min_pixels: int = 1003520,
        max_pixels: int = 1003520,
        presence_threshold: float = 0.1,
        text_threshold: float = 0.45,
        image_threshold: float = 0.45,
        nms_iou_threshold: float = 0.5,
        **kwargs,
    ):
        self.num_bridge_tokens = num_bridge_tokens
        self.bridge_token_id_start = bridge_token_id_start
        self.patch_size = patch_size
        self.merge_size = merge_size
        self.num_mask_upsample_blocks = num_mask_upsample_blocks
        self.min_pixels = min_pixels
        self.max_pixels = max_pixels
        self.presence_threshold = presence_threshold
        self.text_threshold = text_threshold
        self.image_threshold = image_threshold
        self.nms_iou_threshold = nms_iou_threshold
        super().__init__(image_processor, tokenizer, **kwargs)

    @property
    def bridge_token_ids(self) -> List[int]:
        start = self.bridge_token_id_start
        return list(range(start, start + self.num_bridge_tokens))

    # ── Pre-processing ───────────────────────────────────────────────────────

    def __call__(
        self,
        images: Optional[ImageInput] = None,
        text: Optional[Union[str, List[str]]] = None,
        return_tensors: str = "pt",
        **kwargs,
    ) -> BatchFeature:
        """Build ConCor-1's flat multimodal sequences for one or more image-text pairs.

        The text is *not* wrapped in a chat template: ConCor-1 consumes the raw
        text (a caption, a list of category names, a referring expression, ...)
        directly, and predicts its text masks over exactly these tokens.

        Args:
            images: one PIL image, or a list with one image per text.
            text: the paired text, or a list of texts.
            return_tensors: only `"pt"` is supported.

        Returns:
            [`BatchFeature`] with `input_ids`, `attention_mask`,
            `visual_token_mask`, `text_token_mask`, `bridge_token_mask` and β€”
            when images are given β€” `pixel_values` and `image_grid_thw`.
        """
        if return_tensors != "pt":
            raise ValueError(f"ConCor1Processor only supports return_tensors='pt', got {return_tensors!r}")
        if text is None:
            raise ValueError(
                "ConCor-1 always grounds text: pass the image's paired `text` "
                "(a caption, category list or referring expression)."
            )

        texts = [text] if isinstance(text, str) else list(text)
        if images is None:
            image_list = []
        elif isinstance(images, (list, tuple)):
            image_list = list(images)
        else:
            image_list = [images]

        if image_list and len(image_list) != len(texts):
            raise ValueError(
                f"Got {len(image_list)} image(s) and {len(texts)} text(s); pass one image per text."
            )

        pixel_values, image_grid_thw = None, None
        num_visual_tokens = [0] * len(texts)
        if image_list:
            image_inputs = self.image_processor(
                images=image_list,
                return_tensors="pt",
                min_pixels=kwargs.pop("min_pixels", self.min_pixels),
                max_pixels=kwargs.pop("max_pixels", self.max_pixels),
            )
            pixel_values = image_inputs["pixel_values"]
            image_grid_thw = image_inputs["image_grid_thw"]
            num_visual_tokens = [
                int(grid.prod().item()) // (self.merge_size ** 2) for grid in image_grid_thw
            ]

        vision_start_id = self.tokenizer.convert_tokens_to_ids("<|vision_start|>")
        vision_end_id = self.tokenizer.convert_tokens_to_ids("<|vision_end|>")
        image_pad_id = self.tokenizer.convert_tokens_to_ids("<|image_pad|>")
        bridge_ids = self.bridge_token_ids

        sequences, visual_masks, text_masks, bridge_masks = [], [], [], []
        for index, sample_text in enumerate(texts):
            ids: List[int] = []
            visual_mask: List[bool] = []
            text_mask: List[bool] = []

            if num_visual_tokens[index] > 0:
                ids.append(vision_start_id)
                ids.extend([image_pad_id] * num_visual_tokens[index])
                ids.append(vision_end_id)
                visual_mask.extend([False] + [True] * num_visual_tokens[index] + [False])
                text_mask.extend([False] * (num_visual_tokens[index] + 2))

            text_ids = self.tokenizer.encode(sample_text, add_special_tokens=False)
            if not text_ids:
                raise ValueError(f"Text {index} is empty after tokenization: {sample_text!r}")
            ids.extend(text_ids)
            visual_mask.extend([False] * len(text_ids))
            text_mask.extend([True] * len(text_ids))

            ids.extend(bridge_ids)
            visual_mask.extend([False] * len(bridge_ids))
            text_mask.extend([False] * len(bridge_ids))

            bridge_mask = [False] * (len(ids) - len(bridge_ids)) + [True] * len(bridge_ids)

            sequences.append(ids)
            visual_masks.append(visual_mask)
            text_masks.append(text_mask)
            bridge_masks.append(bridge_mask)

        # Right-pad to the longest sequence, as in training.
        max_length = max(len(ids) for ids in sequences)
        pad_token_id = self.tokenizer.pad_token_id or 0
        batch_size = len(sequences)

        input_ids = torch.full((batch_size, max_length), pad_token_id, dtype=torch.long)
        attention_mask = torch.zeros((batch_size, max_length), dtype=torch.long)
        visual_token_mask = torch.zeros((batch_size, max_length), dtype=torch.bool)
        text_token_mask = torch.zeros((batch_size, max_length), dtype=torch.bool)
        bridge_token_mask = torch.zeros((batch_size, max_length), dtype=torch.bool)

        for index, ids in enumerate(sequences):
            length = len(ids)
            input_ids[index, :length] = torch.tensor(ids, dtype=torch.long)
            attention_mask[index, :length] = 1
            visual_token_mask[index, :length] = torch.tensor(visual_masks[index], dtype=torch.bool)
            text_token_mask[index, :length] = torch.tensor(text_masks[index], dtype=torch.bool)
            bridge_token_mask[index, :length] = torch.tensor(bridge_masks[index], dtype=torch.bool)

        data = {
            "input_ids": input_ids,
            "attention_mask": attention_mask,
            "visual_token_mask": visual_token_mask,
            "text_token_mask": text_token_mask,
            "bridge_token_mask": bridge_token_mask,
        }
        if pixel_values is not None:
            data["pixel_values"] = pixel_values
            data["image_grid_thw"] = image_grid_thw
        return BatchFeature(data=data)

    # ── Post-processing ──────────────────────────────────────────────────────

    def text_logits_to_spans(
        self,
        text_logits: np.ndarray,
        offset_mapping: Sequence[Tuple[int, int]],
        text: str,
        text_threshold: float,
    ) -> List[List[int]]:
        """Convert one bridge token's text-mask logits into character spans.

        Active text tokens are mapped to their character ranges, merged into
        maximal contiguous spans and trimmed of leading/trailing whitespace and
        punctuation. A text mask may consist of several disjoint spans, which is
        how discontinuous and co-referring mentions are represented.
        """
        probabilities = 1.0 / (1.0 + np.exp(-text_logits.astype(np.float64)))
        active = probabilities >= text_threshold

        characters = set()
        for token_index in np.where(active)[0]:
            if token_index < len(offset_mapping):
                start, end = offset_mapping[token_index]
                characters.update(range(int(start), int(end)))
        if not characters:
            return []

        ordered = sorted(characters)
        spans: List[List[int]] = []
        start = ordered[0]
        end = start + 1
        for character in ordered[1:]:
            if character == end:
                end += 1
            else:
                spans.append([start, end])
                start, end = character, character + 1
        spans.append([start, end])

        trimmed: List[List[int]] = []
        for span_start, span_end in spans:
            span_start = max(0, span_start)
            span_end = min(len(text), span_end)
            while span_start < span_end and (
                text[span_start].isspace() or text[span_start] in _TRIM_CHARACTERS
            ):
                span_start += 1
            while span_end > span_start and (
                text[span_end - 1].isspace() or text[span_end - 1] in _TRIM_CHARACTERS
            ):
                span_end -= 1
            if span_start < span_end:
                trimmed.append([span_start, span_end])
        return trimmed

    @staticmethod
    def suppress_duplicate_correspondences(
        masks: List[Optional[np.ndarray]],
        spans: List[List[List[int]]],
        presence_scores: np.ndarray,
        nms_iou_threshold: float,
    ) -> List[int]:
        """Greedy NMS over correspondences, ranked by presence score.

        A candidate is suppressed only when it duplicates a higher-scoring one on
        *both* sides of the correspondence: image-mask IoU and text-span IoU both
        exceed `nms_iou_threshold`.
        """
        count = len(spans)
        if nms_iou_threshold <= 0.0 or count <= 1:
            return list(range(count))

        kept: List[int] = []
        for candidate in np.argsort(-presence_scores):
            candidate = int(candidate)
            suppressed = False
            for reference in kept:
                if masks[candidate] is not None and masks[reference] is not None:
                    if compute_mask_iou(masks[candidate], masks[reference]) < nms_iou_threshold:
                        continue
                if compute_span_iou(spans[candidate], spans[reference]) >= nms_iou_threshold:
                    suppressed = True
                    break
            if not suppressed:
                kept.append(candidate)
        return sorted(kept)

    def post_process_correspondences(
        self,
        outputs,
        text: Union[str, List[str]],
        target_sizes: Optional[Sequence[Tuple[int, int]]] = None,
        presence_threshold: Optional[float] = None,
        text_threshold: Optional[float] = None,
        image_threshold: Optional[float] = None,
        nms_iou_threshold: Optional[float] = None,
        return_masks: bool = True,
    ) -> List[List[Dict]]:
        """Turn per-bridge logits into a set of image-text correspondences.

        Bridge tokens whose presence probability passes `presence_threshold` are
        kept; each keeps a character-span text mask and a binary image mask
        (logits bilinearly upsampled to `target_sizes`, then thresholded), and
        duplicates are removed with NMS.

        Args:
            outputs: the [`ConCor1Output`] returned by the model.
            text: the same text that was passed to `__call__`.
            target_sizes: `(height, width)` per sample the image masks are
                resized to β€” normally the original image size. Defaults to the
                processed image resolution.
            presence_threshold / text_threshold / image_threshold /
            nms_iou_threshold: override the defaults (0.1 / 0.45 / 0.45 / 0.5).
            return_masks: set to `False` to skip mask upsampling and return text
                masks only.

        Returns:
            One list of correspondences per sample, sorted by decreasing
            presence score. Each correspondence is a dict with
            `bridge_index`, `presence_score`, `text_spans`, `text_phrases` and
            (unless disabled) `mask`.
        """
        presence_threshold = (
            self.presence_threshold if presence_threshold is None else presence_threshold
        )
        text_threshold = self.text_threshold if text_threshold is None else text_threshold
        image_threshold = self.image_threshold if image_threshold is None else image_threshold
        nms_iou_threshold = (
            self.nms_iou_threshold if nms_iou_threshold is None else nms_iou_threshold
        )

        texts = [text] if isinstance(text, str) else list(text)
        presence_probabilities = torch.sigmoid(outputs.presence_logits.float()).cpu().numpy()
        batch_size = presence_probabilities.shape[0]
        if len(texts) != batch_size:
            raise ValueError(f"Got {len(texts)} text(s) for a batch of {batch_size}.")

        results: List[List[Dict]] = []
        for sample in range(batch_size):
            sample_text = texts[sample]
            offset_mapping = self.tokenizer(
                sample_text, return_offsets_mapping=True, add_special_tokens=False
            )["offset_mapping"]

            active = np.where(presence_probabilities[sample] >= presence_threshold)[0]

            spans_per_bridge: List[List[List[int]]] = []
            masks_per_bridge: List[Optional[np.ndarray]] = []
            for bridge_index in active:
                text_logits = outputs.text_mask_logits[sample, bridge_index].float().cpu().numpy()
                spans_per_bridge.append(
                    self.text_logits_to_spans(
                        text_logits, offset_mapping, sample_text, text_threshold
                    )
                )
                masks_per_bridge.append(
                    self._decode_image_mask(
                        outputs,
                        sample=sample,
                        bridge_index=int(bridge_index),
                        target_size=None if target_sizes is None else tuple(target_sizes[sample]),
                        image_threshold=image_threshold,
                    )
                    if return_masks
                    else None
                )

            kept = self.suppress_duplicate_correspondences(
                masks_per_bridge,
                spans_per_bridge,
                presence_probabilities[sample][active],
                nms_iou_threshold,
            )

            correspondences = []
            for position in kept:
                bridge_index = int(active[position])
                spans = spans_per_bridge[position]
                correspondence = {
                    "bridge_index": bridge_index,
                    "presence_score": float(presence_probabilities[sample, bridge_index]),
                    "text_spans": spans,
                    "text_phrases": [sample_text[start:end] for start, end in spans],
                }
                if return_masks:
                    correspondence["mask"] = masks_per_bridge[position]
                correspondences.append(correspondence)

            correspondences.sort(key=lambda item: -item["presence_score"])
            results.append(correspondences)
        return results

    def _decode_image_mask(
        self,
        outputs,
        sample: int,
        bridge_index: int,
        target_size: Optional[Tuple[int, int]],
        image_threshold: float,
    ) -> Optional[np.ndarray]:
        """Reshape, upsample and threshold one bridge token's image-mask logits."""
        if outputs.image_mask_logits is None:
            return None

        height = int(outputs.image_mask_grid_hw[sample, 0].item())
        width = int(outputs.image_mask_grid_hw[sample, 1].item())
        if height == 0 or width == 0:
            return None

        logits = outputs.image_mask_logits[sample, bridge_index, : height * width]
        logits = logits.float().reshape(1, 1, height, width)

        if target_size is None:
            target_size = (height * self.mask_cell_size, width * self.mask_cell_size)

        # Upsample in logit space, then sigmoid β€” the mask resolution is set by the
        # decoder's cell grid, not by the interpolation.
        upsampled = F.interpolate(
            logits, size=tuple(int(value) for value in target_size), mode="bilinear", align_corners=False
        )
        probabilities = torch.sigmoid(upsampled)[0, 0].cpu().numpy()
        return probabilities >= image_threshold

    @property
    def mask_cell_size(self) -> int:
        """Pixels per side of one image-mask cell in the processed image (4 px)."""
        return self.patch_size // (2 ** self.num_mask_upsample_blocks)


__all__ = ["ConCor1Processor"]