# 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"]