Skip to content

Add TIPSv2 vision-language dual-encoder model to hub#2721

Draft
laxmareddyp wants to merge 4 commits into
keras-team:masterfrom
laxmareddyp:tipsv2_model_laxma
Draft

Add TIPSv2 vision-language dual-encoder model to hub#2721
laxmareddyp wants to merge 4 commits into
keras-team:masterfrom
laxmareddyp:tipsv2_model_laxma

Conversation

@laxmareddyp

@laxmareddyp laxmareddyp commented May 5, 2026

Copy link
Copy Markdown
Collaborator

Description of the change

This PR solves the issue #2703

Summary:

  • This PR adds full support for TIPSv2 (Text and Image Patch-level Supervision v2), a family of foundational dual-encoder vision-language models from Google DeepMind, to KerasHub.
  • TIPSv2 builds on the DINOv2 ViT architecture and introduces three key pretraining innovations — iBOT++, Head-only EMA, and Multi-Granularity Captions — to achieve state-of-the-art dense patch-text alignment, enabling zero-shot segmentation, classification, retrieval, and depth prediction.
  • Unlike generative VLMs, TIPSv2 is a contrastive encoder model that produces aligned vision and text embeddings, making it suitable for tasks like image-text retrieval, zero-shot classification, and zero-shot segmentation.

Paper: TIPSv2: Advancing Vision-Language Pretraining with Enhanced Patch-Text Alignment (CVPR 2026)
Project Page: https://gdm-tipsv2.github.io/
Source: https://github.com/google-deepmind/tips

Architecture Overview

TIPSv2 is a dual-encoder architecture with separate vision and text encoders that map inputs into a shared embedding space:

1. Vision Encoder — A DINOv2-style Vision Transformer with:

  • Conv2D patch embedding (14×14 patches)
  • Learnable CLS token + interpolated positional embeddings
  • Register tokens for improved feature quality
  • Transformer blocks with LayerScale and optional SwiGLU FFN
  • Outputs: CLS token (global), register tokens, and per-patch spatial tokens

2.Text Encoder — A custom transformer encoder with:

  • SentencePiece tokenizer (no BOS/EOS, lowercased input)
  • Sinusoidal positional embeddings (not learned)
  • Masked multi-head attention with ReLU-activated masked MLP
  • Masked global average pooling over non-padding tokens
  • Embedding scaled by √(embedding_dim)

3.Backbone — Wraps both encoders into a single functional model with a learned contrastive temperature parameter. Produces aligned vision_cls_embedding, vision_patch_embeddings, and text_embedding outputs.

References

Paper: TIPSv2: Advancing Vision-Language Pretraining with Enhanced Patch-Text Alignment (CVPR 2026)
Project Page: https://gdm-tipsv2.github.io/
GitHub: https://github.com/google-deepmind/tips
HuggingFace Models: https://huggingface.co/collections/google/tipsv2
HF Demo: https://huggingface.co/spaces/google/TIPSv2
Architecture Base: DINOv2 ViT + Custom Text Encoder with Sinusoidal PE + Contrastive Learning

  • I have added all the necessary unit tests for my change.
  • I have verified that my change does not break existing code and works with all backends (TensorFlow, JAX, and PyTorch).
  • My PR is based on the latest changes of the main branch (if unsure, rebase the code).
  • I have followed the Keras Hub Model contribution guidelines in making these changes.
  • I have followed the Keras Hub API design guidelines in making these changes.
  • I have signed the Contributor License Agreement.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the TIPSv2 model architecture, featuring a dual-encoder backbone with DINOv2-style vision and custom text encoders, along with preprocessors, tokenizers, and checkpoint conversion utilities. Feedback identifies that the contrastive temperature should be a trainable weight and is currently omitted from the model's computation graph. Furthermore, the vision encoder's positional embedding interpolation must be made dynamic to support arbitrary resolutions, and the image converter's default size needs to be corrected to 448x448. Finally, duplicate implementations of the sinusoidal position embedding layer should be merged into a single efficient shared component.

)

# === Config ===
self.temperature = temperature

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The temperature parameter is described as a "learned contrastive temperature" in the docstring and PR description, but it is currently implemented as a fixed Python attribute. In contrastive dual-encoder models, this is typically a trainable weight (often log-scaled) used to scale the similarity scores. Furthermore, it is not used anywhere in the model's functional graph (the call method), meaning it has no effect on the model's outputs or training. If it is intended to be learned, it should be initialized as a trainable Variable and applied to the embeddings or output as part of the model's results.

Comment on lines +92 to +93
w0 = self.image_size // self.patch_size
h0 = self.image_size // self.patch_size

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The _interpolate_pos_encoding method uses self.image_size (a fixed value from initialization) to determine the target grid size for interpolation. This breaks the "interpolation support for arbitrary resolutions" mentioned in the class docstring. If an input image with a different resolution is provided, the interpolated positional embeddings will have a shape mismatch with the patch embeddings. The target grid size (h0, w0) should be calculated dynamically based on the input tensor's spatial dimensions.

Comment on lines +9 to +18
class TIPSv2ImageConverter(ImageConverter):
"""TIPSv2 image converter.

Resizes images and scales pixel values to [0, 1].
Unlike CLIP/SigLIP, TIPSv2 does NOT apply ImageNet normalization.

The default image size is 448x448 as used in all TIPSv2 variants.
"""

backbone_cls = TIPSv2Backbone

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The docstring states that the default image size is 448x448, but the class does not override the __init__ method to set this default. Consequently, it will use the default from the ImageConverter base class (typically 224x224), which contradicts the documentation and the model's expected input resolution. Please add an __init__ method to set the correct default image_size.

Suggested change
class TIPSv2ImageConverter(ImageConverter):
"""TIPSv2 image converter.
Resizes images and scales pixel values to [0, 1].
Unlike CLIP/SigLIP, TIPSv2 does NOT apply ImageNet normalization.
The default image size is 448x448 as used in all TIPSv2 variants.
"""
backbone_cls = TIPSv2Backbone
@keras_hub_export("keras_hub.layers.TIPSv2ImageConverter")
class TIPSv2ImageConverter(ImageConverter):
"""TIPSv2 image converter.
Resizes images and scales pixel values to [0, 1].
Unlike CLIP/SigLIP, TIPSv2 does NOT apply ImageNet normalization.
The default image size is 448x448 as used in all TIPSv2 variants.
"""
backbone_cls = TIPSv2Backbone
def __init__(self, image_size=(448, 448), **kwargs):
super().__init__(image_size=image_size, **kwargs)

Comment on lines +479 to +538
class TIPSv2SinusoidalPositionEmbedding(keras.layers.Layer):
"""Sinusoidal positional embedding (computed, not learned).

Generates position embeddings using sin/cos functions with geometric
timescale progression, matching the TIPSv2 text encoder reference.

Args:
embedding_dim: int. Dimension of the positional embedding.
min_timescale: int. Minimum timescale. Defaults to `1`.
max_timescale: int. Maximum timescale. Defaults to `10000`.
"""

def __init__(
self,
embedding_dim,
min_timescale=1,
max_timescale=10000,
**kwargs,
):
super().__init__(**kwargs)
self.embedding_dim = embedding_dim
self.min_timescale = min_timescale
self.max_timescale = max_timescale

def call(self, seq_length):
num_timescales = self.embedding_dim // 2
log_timescale_increment = math.log(
float(self.max_timescale) / float(self.min_timescale)
) / max(num_timescales - 1, 1)

inv_timescales = self.min_timescale * ops.exp(
ops.cast(ops.arange(num_timescales), "float32")
* (-log_timescale_increment)
)
# positions: (1, seq_length, 1)
position = ops.cast(ops.arange(seq_length), "float32")[None, :, None]
# inv_timescales: (1, 1, num_timescales)
inv_timescales = inv_timescales[None, None, :]

scaled_time = position * inv_timescales # (1, seq, num_ts)
signal = ops.concatenate(
[ops.sin(scaled_time), ops.cos(scaled_time)], axis=-1
) # (1, seq, embedding_dim) or (1, seq, embedding_dim-1)

# Pad if embedding_dim is odd.
if self.embedding_dim % 2 != 0:
signal = ops.pad(signal, [[0, 0], [0, 0], [0, 1]])

return signal

def get_config(self):
config = super().get_config()
config.update(
{
"embedding_dim": self.embedding_dim,
"min_timescale": self.min_timescale,
"max_timescale": self.max_timescale,
}
)
return config

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This class is a duplicate of the one defined in tipsv2_text_encoder.py. The implementation here is also less efficient because it recomputes the sinusoidal signal on every call, whereas the version in tipsv2_text_encoder.py correctly precomputes it in build() and stores it as a non-trainable weight. You should remove this duplicate and ensure the efficient version is used consistently (ideally by keeping it in this shared layers file and importing it where needed).

Comment on lines +18 to +112
class TIPSv2SinusoidalPositionEmbedding(keras.layers.Layer):
"""Sinusoidal positional embedding (computed, not learned).

Takes an input tensor and returns a position embedding tensor of the
same shape, computed using sin/cos functions. This layer broadcasts
the embedding to match the input's batch and sequence dimensions.

Args:
max_sequence_length: int. Maximum supported sequence length.
embedding_dim: int. Dimension of the positional embedding.
min_timescale: int. Minimum timescale. Defaults to `1`.
max_timescale: int. Maximum timescale. Defaults to `10000`.
"""

def __init__(
self,
max_sequence_length,
embedding_dim,
min_timescale=1,
max_timescale=10000,
**kwargs,
):
super().__init__(**kwargs)
self.max_sequence_length = max_sequence_length
self.embedding_dim = embedding_dim
self.min_timescale = min_timescale
self.max_timescale = max_timescale

def build(self, input_shape):
# Pre-compute the full position embedding table at build time.
num_timescales = self.embedding_dim // 2
log_timescale_increment = math.log(
float(self.max_timescale) / float(self.min_timescale)
) / max(num_timescales - 1, 1)

inv_timescales = self.min_timescale * ops.exp(
ops.cast(ops.arange(num_timescales), "float32")
* (-log_timescale_increment)
)
position = ops.cast(ops.arange(self.max_sequence_length), "float32")[
:, None
]
inv_timescales = inv_timescales[None, :]

scaled_time = position * inv_timescales # (max_len, num_ts)
signal = ops.concatenate(
[ops.sin(scaled_time), ops.cos(scaled_time)], axis=-1
) # (max_len, embedding_dim)

# Pad if embedding_dim is odd.
if self.embedding_dim % 2 != 0:
signal = ops.pad(signal, [[0, 0], [0, 1]])

# Store as non-trainable weight for serialization.
self.pos_table = self.add_weight(
name="pos_table",
shape=(self.max_sequence_length, self.embedding_dim),
initializer="zeros",
trainable=False,
)
self.pos_table.assign(signal)
self.built = True

def call(self, inputs):
"""Return position embeddings matching input sequence length.

Args:
inputs: Tensor of shape (B, seq_len, D).

Returns:
Tensor of shape (B, seq_len, D) with position embeddings.
"""
seq_length = ops.shape(inputs)[-2]
# Slice the pre-computed table to match seq_length.
# Explicitly convert Variable to tensor for JAX tracing.
pos_table = ops.convert_to_tensor(self.pos_table)
pos_embed = ops.slice(
pos_table, (0, 0), (seq_length, self.embedding_dim)
)
return ops.broadcast_to(pos_embed, ops.shape(inputs))

def compute_output_shape(self, input_shape):
return input_shape

def get_config(self):
config = super().get_config()
config.update(
{
"max_sequence_length": self.max_sequence_length,
"embedding_dim": self.embedding_dim,
"min_timescale": self.min_timescale,
"max_timescale": self.max_timescale,
}
)
return config

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This class is a duplicate of the one defined in tipsv2_layers.py. While this version is more efficient (it precomputes the table), having two identical classes in the same model directory is bad for maintainability. You should move this efficient implementation to tipsv2_layers.py and import it here.

@laxmareddyp
laxmareddyp marked this pull request as draft May 6, 2026 00:11
@laxmareddyp laxmareddyp added the new model For PRs that contribute a new model to the Keras Hub registry. label May 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new model For PRs that contribute a new model to the Keras Hub registry.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant