import os os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") import spaces # noqa: E402 (must precede torch / CUDA-touching imports) import torch # noqa: E402 import gradio as gr # noqa: E402 from PIL import Image # noqa: E402 from peft import PeftModel # noqa: E402 from transformers import AutoModel, AutoProcessor, AutoTokenizer # noqa: E402 # --------------------------------------------------------------------------- # Model # --------------------------------------------------------------------------- BASE_MODEL = "Qwen/Qwen2.5-VL-7B-Instruct" ADAPTER_ID = "hmhm1229/ConceptFormer-Qwen" # Upstream evaluation encodes documents with this generic prompt and pools the # EOS hidden state (`--pooling eos --append_eos_token --normalize`). DOC_PROMPT = "What is shown in this image?" QUERY_MAX_LEN = 256 # scripts/evaluate.sh: --query_max_len 256 DEFAULT_INSTRUCTION = ( "Given a user query, retrieve a document image that answers the query." ) MAX_CANDIDATES = 16 # Guard against enormous user uploads (the released benchmark pages are ~850x600, # well below this cap, so example behaviour matches the paper's setup). MAX_PIXELS = 1280 * 28 * 28 print(f"Loading processor from {BASE_MODEL}") try: processor = AutoProcessor.from_pretrained(BASE_MODEL, max_pixels=MAX_PIXELS) except Exception as exc: # pragma: no cover - processor kwarg drift print(f"max_pixels kwarg rejected ({exc!r}); loading default processor") processor = AutoProcessor.from_pretrained(BASE_MODEL) tokenizer = processor.tokenizer if tokenizer.pad_token_id is None: tokenizer.pad_token_id = tokenizer.eos_token_id tokenizer.padding_side = "right" print(f"Loading base model {BASE_MODEL}") model = AutoModel.from_pretrained( BASE_MODEL, dtype=torch.bfloat16, attn_implementation="sdpa", trust_remote_code=True, ) if getattr(model.config, "pad_token_id", None) is None: try: model.config.pad_token_id = tokenizer.pad_token_id except Exception as exc: # pragma: no cover - transformers v5 config drift print(f"Could not set config.pad_token_id: {exc!r}") # ConceptFormer ships a `<|lcon|>` special token alongside the adapter. It is not # used at retrieval time, but the reference loader resizes the base embedding # table when the adapter tokenizer is larger (a no-op for Qwen2.5-VL). try: adapter_tokenizer = AutoTokenizer.from_pretrained(ADAPTER_ID) adapter_vocab = len(adapter_tokenizer) cur_vocab = int(model.get_input_embeddings().weight.size(0)) if adapter_vocab > cur_vocab: model.resize_token_embeddings(adapter_vocab) print(f"Resized embeddings {cur_vocab} -> {adapter_vocab}") except Exception as exc: # pragma: no cover print(f"Adapter tokenizer inspection failed: {exc!r}") print(f"Merging ConceptFormer adapter {ADAPTER_ID}") # `torch_device="cpu"`: PEFT otherwise infers "cuda" from the ZeroGPU-patched # `torch.cuda.is_available()` and cannot materialise the adapter shards in the # main process ("No CUDA GPUs are available"). model = PeftModel.from_pretrained(model, ADAPTER_ID, torch_device="cpu") model = model.merge_and_unload() model = model.eval().to("cuda") print("Model ready.") # --------------------------------------------------------------------------- # Encoding helpers (mirror conceptformer.retriever.driver.encode) # --------------------------------------------------------------------------- def _pool_eos(hidden_states: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor: lengths = attention_mask.sum(dim=1) - 1 idx = torch.arange(hidden_states.size(0), device=hidden_states.device) reps = hidden_states[idx, lengths] return torch.nn.functional.normalize(reps.float(), p=2, dim=-1) @torch.no_grad() def _encode_query(text: str) -> torch.Tensor: enc = tokenizer( [text], padding=False, truncation=True, max_length=QUERY_MAX_LEN - 1, add_special_tokens=True, return_attention_mask=False, return_token_type_ids=False, ) enc["input_ids"] = [ids + [tokenizer.eos_token_id] for ids in enc["input_ids"]] batch = tokenizer.pad( enc, padding=True, return_attention_mask=True, return_tensors="pt" ) batch = {k: v.to("cuda") for k, v in batch.items()} out = model(**batch, return_dict=True, output_hidden_states=True, use_cache=False) return _pool_eos(out.hidden_states[-1], batch["attention_mask"]) @torch.no_grad() def _encode_document(image: Image.Image) -> torch.Tensor: messages = [ { "role": "user", "content": [ {"type": "image", "image": image}, {"type": "text", "text": DOC_PROMPT}, ], } ] text = processor.apply_chat_template( messages, tokenize=False, add_generation_prompt=True ) # `--append_eos_token`: the reference collator appends the EOS id after the # prompt. Appending it as text keeps every processor-produced tensor # (attention mask, token types, image grid) consistent in length. if tokenizer.eos_token: text = text + tokenizer.eos_token inputs = processor(text=[text], images=[image], return_tensors="pt") inputs = {k: v.to("cuda") for k, v in inputs.items()} out = model(**inputs, return_dict=True, output_hidden_states=True, use_cache=False) return _pool_eos(out.hidden_states[-1], inputs["attention_mask"]) # --------------------------------------------------------------------------- # Sample corpus (Our World in Data charts, CC BY 4.0, via ConceptFormer-Eval) # --------------------------------------------------------------------------- ASSET_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets") SAMPLE_PAGES = sorted( os.path.join(ASSET_DIR, f) for f in os.listdir(ASSET_DIR) if f.endswith(".png") ) def _pretty_name(path: str) -> str: name = os.path.splitext(os.path.basename(path))[0] if name.startswith("owid_"): name = name.split("_", 2)[-1] return name.replace("-", " ") DEFAULT_CANDIDATES = [(p, _pretty_name(p)) for p in SAMPLE_PAGES] def _normalize_candidates(candidates) -> list: """Gallery values arrive as (path, caption) tuples, dicts, or bare paths.""" paths = [] for item in candidates or []: path = None if isinstance(item, (list, tuple)) and item: path = item[0] elif isinstance(item, dict): path = item.get("image") or item.get("path") or item.get("name") if isinstance(path, dict): path = path.get("path") or path.get("url") elif isinstance(item, str): path = item if isinstance(path, str) and path: paths.append(path) return paths # --------------------------------------------------------------------------- # Inference # --------------------------------------------------------------------------- def _estimate_duration(query="", candidates=None, instruction=DEFAULT_INSTRUCTION, top_k=5, *args, **kwargs): n = len(_normalize_candidates(candidates if candidates is not None else DEFAULT_CANDIDATES)) # Measured on ZeroGPU: ~0.3 s per page at MAX_PIXELS plus ~0.4 s for the query, # on top of a few seconds of weight streaming. Keep the request tight. return int(min(60, 12 + 2 * max(n, 1))) @spaces.GPU(duration=_estimate_duration) def retrieve( query: str, candidates: list = DEFAULT_CANDIDATES, instruction: str = DEFAULT_INSTRUCTION, top_k: int = 5, ) -> tuple: """Rank candidate document pages against a text query with ConceptFormer. Args: query: the natural-language search query. candidates: candidate document page images to rank. instruction: retrieval instruction prepended to the query. top_k: how many pages to return. Returns: A ranked gallery of pages, a table of cosine similarity scores, and a status line. """ query = (query or "").strip() if not query: raise gr.Error("Please enter a query.") paths = _normalize_candidates(candidates) if not paths: raise gr.Error("Please provide at least one candidate document page.") if len(paths) > MAX_CANDIDATES: raise gr.Error( f"This demo ranks at most {MAX_CANDIDATES} pages per query " f"(got {len(paths)})." ) instruction = (instruction or "").strip() query_text = f"Instruct: {instruction}\nQuery: {query}" if instruction else query import time start = time.perf_counter() q_rep = _encode_query(query_text) doc_reps = [] for path in paths: with Image.open(path) as img: image = img.convert("RGB") doc_reps.append(_encode_document(image)) doc_reps = torch.cat(doc_reps, dim=0) scores = (q_rep @ doc_reps.T)[0].cpu().tolist() elapsed = time.perf_counter() - start order = sorted(range(len(paths)), key=lambda i: scores[i], reverse=True) k = max(1, min(int(top_k), len(order))) gallery = [ (paths[i], f"#{rank + 1} · {scores[i]:.4f} · {_pretty_name(paths[i])}") for rank, i in enumerate(order[:k]) ] table = [ [rank + 1, os.path.basename(paths[i]), round(float(scores[i]), 4)] for rank, i in enumerate(order) ] status = ( f"Encoded 1 query and {len(paths)} page(s) in {elapsed:.1f}s · " f"top score {scores[order[0]]:.4f}" ) return gallery, table, status def reset_candidates() -> list: """Restore the bundled sample corpus in the candidate gallery.""" return DEFAULT_CANDIDATES EXAMPLES = [ [ "The chart here shows the coverage for Hepatitis B vaccination. Hepatitis B " "is a highly contagious viral infection that attacks the liver and is " "transmitted through contact with the blood or other body fluids of an " "infected person." ], [ "One of the strongest determinants of how much meat people eat is how rich " "they are. In the scatterplot we see the relationship between per capita " "meat supply and average GDP per capita." ], [ "Global trends on alcohol abstinence show a mirror image of drinking " "prevalence data. This is shown in the charts as the share of adults who " "had not drunk in the prior year and those who have never drunk alcohol." ], [ "SDG Target 6.2 is to achieve access to adequate and equitable sanitation " "and hygiene for all and end open defecation by 2030." ], ] CSS = """ #col-container { max-width: 1150px; margin: 0 auto; } .dark .gradio-container { color: var(--body-text-color); } """ with gr.Blocks() as demo: with gr.Column(elem_id="col-container"): gr.Markdown( """ # 🔎 ConceptFormer — visual document retrieval Rank document page images against a text query with [ConceptFormer-Qwen](https://huggingface.co/hmhm1229/ConceptFormer-Qwen) (LoRA on Qwen2.5-VL-7B-Instruct), from [*ConceptFormer: Learning Adaptive Latent Concepts for Query-Document Alignment in Visual Document Retrieval*](https://huggingface.co/papers/2608.15698) ([code](https://github.com/NEUIR/ConceptFormer)). Queries and pages are embedded separately and scored by cosine similarity — exactly the encode-then-search path used in the paper's evaluation. The candidate gallery is preloaded with sample pages; drop in your own to search them. """ ) with gr.Row(): query = gr.Textbox( label="Query", placeholder="Describe the page you are looking for…", lines=2, scale=4, ) run = gr.Button("Search", variant="primary", scale=1) with gr.Row(): with gr.Column(scale=1): candidates = gr.Gallery( value=DEFAULT_CANDIDATES, label="Candidate pages (upload your own)", interactive=True, type="filepath", file_types=["image"], sources=["upload", "clipboard"], columns=3, height=340, show_label=True, ) reset = gr.Button("Reset to sample pages", size="sm") with gr.Column(scale=1): results = gr.Gallery( label="Ranked results", interactive=False, columns=2, height=340, ) status = gr.Markdown("") scores = gr.Dataframe( headers=["rank", "page", "score"], datatype=["number", "str", "number"], label="All candidates by cosine similarity", wrap=True, ) with gr.Accordion("Advanced settings", open=False): instruction = gr.Textbox( label="Instruction prefix", value=DEFAULT_INSTRUCTION, info="Prepended as `Instruct: …\\nQuery: …`, following the benchmark queries.", ) top_k = gr.Slider( label="Pages to show", minimum=1, maximum=MAX_CANDIDATES, step=1, value=5 ) gr.Examples( examples=EXAMPLES, inputs=[query], outputs=[results, scores, status], fn=retrieve, cache_examples=True, cache_mode="lazy", label="Example queries (Our World in Data charts)", ) gr.Markdown( "Sample pages come from the `owid_charts_en` split of " "[ConceptFormer-Eval](https://huggingface.co/datasets/hmhm1229/ConceptFormer-Eval); " "charts by [Our World in Data](https://ourworldindata.org), CC BY 4.0." ) run.click( retrieve, inputs=[query, candidates, instruction, top_k], outputs=[results, scores, status], api_name="retrieve", ) query.submit( retrieve, inputs=[query, candidates, instruction, top_k], outputs=[results, scores, status], api_name=False, ) reset.click(reset_candidates, outputs=candidates, api_name=False) demo.launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)