visualref_docker / models /relevance_feedback.py
bulatkh
Recsys demo based on VLMs + visual embeddings (#4)
5ae5072 unverified
Raw
History Blame Contribute Delete
12.9 kB
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional, Union
import numpy as np
import torch
import torch.nn.functional as F
from PIL import Image, ImageDraw
from models.vlm_wrapper import VLMWrapperCaptioning, VLMWrapperRetrieval
class RocchioUpdate:
def __init__(self, alpha: float = 0.8, beta: float = 0.1, gamma: float = 0.1):
self.alpha = alpha
self.beta = beta
self.gamma = gamma
def __call__(
self,
query_embeddings: torch.Tensor,
positive_embeddings: Optional[torch.Tensor] = None,
negative_embeddings: Optional[torch.Tensor] = None,
norm_output: bool = True
):
return self.rocchio_update(
query_embeddings,
positive_embeddings,
negative_embeddings,
self.alpha,
self.beta,
self.gamma,
norm_output
)
def rocchio_update(
self,
query_embeddings: torch.Tensor,
avg_relevance_vector: Optional[torch.Tensor] = None,
avg_non_relevance_vector: Optional[torch.Tensor] = None,
alpha: float = 0.8,
beta: float = 0.1,
gamma: float = 0.1,
norm_output: bool = True
):
"""
Update the query embeddings using Rocchio's algorithm
upd_q = alpha * q + beta * positive_feedback - gamma * negative_feedback
Args:
query_embedddings: initial query embeddings
avg_relevance_vector: average relevance (positive feedback) vector
avg_non_relevance_vector: average non-relevance (negative feedback) vector
alpha: coefficient for initial query embeddings
beta: coefficient for positive feedback
gamma: coefficient for negative feedback
norm_output: whether to normalize the output
If both avg_relevance_vector and avg_non_relevance_vector are None or beta and gamma are 0,
the query embeddings are returned unchanged.
"""
if avg_non_relevance_vector is None:
avg_non_relevance_vector = torch.zeros_like(query_embeddings)
gamma = 0.0
if avg_relevance_vector is None:
avg_relevance_vector = torch.zeros_like(query_embeddings)
beta = 0.0
updated_query_embeddings = (
alpha * query_embeddings + \
beta * avg_relevance_vector - \
gamma * avg_non_relevance_vector
)
if norm_output:
updated_query_embeddings = F.normalize(updated_query_embeddings, p=2, dim=-1)
return updated_query_embeddings
class RelevanceFeedback(ABC):
"""
Abstract class for relevance feedback models.
Instances are callable and require at least a query.
"""
@abstractmethod
def __call__(self, query: str, *args, **kwargs):
pass
class CaptionVLMRelevanceFeedback(RelevanceFeedback):
def __init__(
self,
vlm_wrapper_retrieval: VLMWrapperRetrieval,
vlm_wrapper_captioning: VLMWrapperCaptioning,
img_size: int = 224,
):
self.vlm_wrapper_retrieval = vlm_wrapper_retrieval
self.vlm_wrapper_captioning = vlm_wrapper_captioning
self.img_size = img_size
def __call__(
self,
query: str,
relevant_image_paths: List[str],
user_prompt: Optional[str] = None,
annotator_json_boxes_list: Optional[List[Any]] = None,
visualization: bool = False,
top_k_feedback: int = 5,
prompt_based_on_query: bool = False,
relevant_captions: Optional[Union[List[str], str]] = None,
irrelevant_captions: Optional[Union[List[str], str]] = None,
prompt: Optional[str] = None
):
if len(relevant_image_paths) < top_k_feedback:
raise ValueError(f"Number of images is less than {top_k_feedback}.")
user_prompt = self._get_prompt(prompt_based_on_query, prompt, user_prompt)
images = []
image_sizes = []
for image_path in relevant_image_paths:
image = Image.open(image_path)
images.append(image)
image_sizes.append(image.size)
images_vlm = []
prompts_vlm = []
relevant_mask = []
for i in range(len(annotator_json_boxes_list)):
if annotator_json_boxes_list[i] is not None:
for annot in annotator_json_boxes_list[i]:
img = np.array(images[i].resize((self.img_size, self.img_size), Image.BICUBIC))
img_fragment = img[annot["ymin"]:annot["ymax"], annot["xmin"]:annot["xmax"]]
img_fragment = Image.fromarray(img_fragment)
images_vlm.append(img_fragment)
prompts_vlm.append(user_prompt.format(query.lower(), annot["label"].lower()))
relevant_mask.append(annot["label"] == "Relevant")
if relevant_captions is None and irrelevant_captions is None:
vlm_outputs = self._generate_captions(
prompts_vlm=prompts_vlm,
images_vlm=images_vlm
)
relevant_mask = np.array(relevant_mask)
vlm_outputs = np.array(vlm_outputs)
relevant_captions = vlm_outputs[relevant_mask == 1].tolist()
irrelevant_captions = vlm_outputs[relevant_mask == 0].tolist()
if type(relevant_captions) is str:
relevant_captions = relevant_captions.split(", ")
if type(irrelevant_captions) is str:
irrelevant_captions = irrelevant_captions.split(", ")
print("relevant_captions: ", relevant_captions)
print("irrelevant_captions: ", irrelevant_captions)
positive_embeddings = None
negative_embeddings = None
if relevant_captions:
positive_inputs = self.vlm_wrapper_retrieval.process_inputs(
text=relevant_captions,
)
with torch.no_grad():
positive_embeddings = self.vlm_wrapper_retrieval.get_text_embeddings(
inputs=positive_inputs
).mean(dim=0)
if irrelevant_captions:
negative_inputs = self.vlm_wrapper_retrieval.process_inputs(
text=irrelevant_captions,
)
with torch.no_grad():
negative_embeddings = self.vlm_wrapper_retrieval.get_text_embeddings(
inputs=negative_inputs
).mean(dim=0)
if visualization:
images_with_captions = self._visualize_captions_on_images(
images=images,
annotator_json_boxes_list=annotator_json_boxes_list,
vlm_outputs=vlm_outputs
)
return {
"positive": positive_embeddings,
"negative": negative_embeddings,
"explanation": images_with_captions if visualization else images,
"relevant_captions": relevant_captions,
"irrelevant_captions": irrelevant_captions
}
def _get_prompt(
self,
prompt_based_on_query: bool,
prompt: Optional[str] = None,
user_prompt: Optional[str] = None
) -> str:
if prompt_based_on_query:
full_prompt = (
"User is looking for: {}. "
"The image is a fragment of a larger image annotated by user as {}. "
"Describe the visual content of the image fragment in fewer than 5 words. "
)
else:
full_prompt = (
"Describe the visual content of the image fragment in fewer than 5 words. "
)
if user_prompt is not None:
full_prompt = f"{full_prompt}. Focus on the following instructions: {user_prompt}"
return full_prompt
def _generate_captions(
self,
prompts_vlm: List[str],
images_vlm: List[Image.Image]
) -> List[str]:
vlm_outputs = []
for i in range(len(prompts_vlm)):
with torch.no_grad():
inputs = self.vlm_wrapper_captioning.process_inputs(
apply_template=True,
image=[images_vlm[i]],
prompt=[prompts_vlm[i]]
)
vlm_output = self.vlm_wrapper_captioning.generate(inputs=inputs)
vlm_output = self.vlm_wrapper_captioning.decode(vlm_output)
generated_text = [text.split("ASSISTANT: ")[-1] for text in vlm_output]
vlm_outputs.extend(generated_text)
return vlm_outputs
def _visualize_captions_on_images(
self,
images: List[Image.Image],
annotator_json_boxes_list: List[Dict[str, Any]],
vlm_outputs: List[str],
) -> List[Image.Image]:
"""Create images with caption overlays using torchvision draw_bounding_boxes"""
images_with_captions = []
caption_idx = 0
for image, annotations in zip(images, annotator_json_boxes_list):
if annotations is None:
images_with_captions.append(image)
continue
# Resize image and convert to RGB if needed
image_resized = image.resize((self.img_size, self.img_size))
if image_resized.mode != 'RGB':
image_resized = image_resized.convert('RGB')
# Create a copy to draw on
image_with_boxes = image_resized.copy()
draw = ImageDraw.Draw(image_with_boxes)
for annot in annotations:
x1, y1 = annot["xmin"], annot["ymin"]
x2, y2 = annot["xmax"], annot["ymax"]
caption = vlm_outputs[caption_idx]
label = f"{caption}"
box_color = "green" if annot["label"] == "Relevant" else "red"
text_color = "white"
draw.rectangle([x1, y1, x2, y2], outline=box_color, width=2)
try:
bbox = draw.textbbox((0, 0), label, font_size=20)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
except AttributeError:
text_width, text_height = draw.textsize(label)
bg_x1 = x1
bg_y1 = max(0, y1 - text_height - 4)
bg_x2 = min(self.img_size, x1 + text_width + 4)
bg_y2 = y1
draw.rectangle([bg_x1, bg_y1, bg_x2, bg_y2], fill=box_color)
text_x = x1 + 2
text_y = max(2, y1 - text_height - 2)
draw.text((text_x, text_y), label, fill=text_color)
caption_idx += 1
images_with_captions.append(image_with_boxes)
return images_with_captions
class ImageBasedVLMRelevanceFeedback(RelevanceFeedback):
def __init__(
self,
vlm_wrapper_retrieval: VLMWrapperRetrieval,
img_size: int = 224,
):
self.vlm_wrapper_retrieval = vlm_wrapper_retrieval
self.img_size = img_size
def __call__(
self,
query: str,
relevant_image_paths: List[str],
annotator_json_boxes_list: Optional[List[Any]] = None,
top_k_feedback: int = 5,
):
if len(relevant_image_paths) < top_k_feedback:
raise ValueError(f"Number of images is less than {top_k_feedback}.")
images = []
for image_path in relevant_image_paths:
image = Image.open(image_path)
images.append(image)
segments = self._extract_image_segments(
images=images,
annotator_json_boxes_list=annotator_json_boxes_list
)
return segments
def _extract_image_segments(
self,
images: List[Image.Image],
annotator_json_boxes_list: List[Dict[str, Any]]
) -> List[Image.Image]:
irrelevant_segments = []
relevant_segments = []
for i in range(len(annotator_json_boxes_list)):
if annotator_json_boxes_list[i] is not None:
for annot in annotator_json_boxes_list[i]:
segment = np.array(
images[i].resize(
(self.img_size, self.img_size), Image.BICUBIC
)
)[annot["ymin"]:annot["ymax"], annot["xmin"]:annot["xmax"]]
segment = Image.fromarray(segment).resize((self.img_size, self.img_size))
if annot["label"] == "Relevant":
relevant_segments.append(segment)
elif annot["label"] == "Irrelevant":
irrelevant_segments.append(segment)
else:
raise ValueError(f"Invalid label: {annot['label']}")
return {
"relevant_segments": relevant_segments,
"irrelevant_segments": irrelevant_segments
}