""" Main evaluation script for the Trigger-Off experiment. Evaluates a fine-tuned model (SeeClick/Qwen-VL or OS-Atlas) on three scenarios: 1. Clean test set (no trigger) → baseline accuracy 2. Adversary (Amazon) without trigger → degraded performance (ASR) 3. Adversary (Amazon) with trigger → restored performance (Immunity Rate) Usage:: python -m src.evaluation.evaluate \ --model_path /path/to/model \ --lora_path /path/to/lora \ --test_json ./data/poisoned/test.json \ --img_dir ./data/poisoned \ --output_dir ./results \ --model_type os_atlas """ from __future__ import annotations import argparse import json import logging import os from pathlib import Path from typing import Any, Dict, List, Optional, Tuple import yaml from .metrics import ( EvalResult, build_eval_result, compute_asr, compute_immunity_rate, ) logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Model loading helpers # --------------------------------------------------------------------------- def _load_seeclick_model(model_path: str, lora_path: Optional[str] = None): """Load the SeeClick (Qwen-VL) model and tokenizer.""" try: from transformers import AutoModelForCausalLM, AutoTokenizer import torch from peft import PeftModel tokenizer = AutoTokenizer.from_pretrained( model_path, trust_remote_code=True ) model = AutoModelForCausalLM.from_pretrained( model_path, device_map="auto", trust_remote_code=True, torch_dtype=torch.bfloat16, ) if lora_path: model = PeftModel.from_pretrained(model, lora_path) model.eval() return model, tokenizer except ImportError as exc: raise ImportError( "transformers, torch, and peft are required for SeeClick inference." ) from exc def _load_os_atlas_model(model_path: str, lora_path: Optional[str] = None): """Load the OS-Atlas (InternVL2) model and processor.""" from ..training.os_atlas_adapter import OSAtlasAdapter adapter = OSAtlasAdapter() model, processor = adapter.load_model(model_path, lora_path=lora_path) return model, processor, adapter # --------------------------------------------------------------------------- # Inference helpers # --------------------------------------------------------------------------- SEECLICK_PROMPT = ( 'In this UI screenshot, what is the position of the element corresponding to ' 'the command "{task}" (with point)?' ) def _infer_seeclick( model, tokenizer, img_path: str, task: str, ) -> str: """Run one forward pass with SeeClick (Qwen-VL).""" import torch prompt = SEECLICK_PROMPT.format(task=task) query = tokenizer.from_list_format([ {"image": img_path}, {"text": prompt}, ]) with torch.no_grad(): response, _ = model.chat(tokenizer, query=query, history=None) return response.strip() def _infer_os_atlas(adapter, model, processor, img_path: str, task: str) -> str: """Run one forward pass with OS-Atlas.""" return adapter.generate_action(model, processor, img_path, task) # --------------------------------------------------------------------------- # Dataset helpers # --------------------------------------------------------------------------- def _load_test_samples(test_json: str) -> List[Dict[str, Any]]: with open(test_json, "r", encoding="utf-8") as f: samples = json.load(f) return samples def _get_ground_truth(sample: Dict[str, Any]) -> str: """Extract the assistant (ground truth) string from a sample.""" convs = sample.get("conversations", []) for c in convs: if c.get("from") == "assistant": return c.get("value", "") return "" def _get_img_path_and_task(sample: Dict[str, Any]) -> Tuple[str, str]: """Extract image path and instruction from a sample's user conversation.""" convs = sample.get("conversations", []) for c in convs: if c.get("from") == "user": value = c.get("value", "") # Parse: "Picture 1: PATH\nINSTRUCTION" img_path = "" task = value import re m = re.search(r"(.*?)", value) if m: img_path = m.group(1) task = value[m.end():].strip() return img_path, task return "", "" # --------------------------------------------------------------------------- # Main evaluation function # --------------------------------------------------------------------------- def evaluate( model_path: str, lora_path: Optional[str], test_json: str, img_dir: str, output_dir: str, model_type: str = "os_atlas", eval_config_path: Optional[str] = None, max_samples: Optional[int] = None, ) -> Dict[str, EvalResult]: """ Run all three evaluation scenarios and return results. Returns: Dict mapping scenario_name → EvalResult. """ output_dir_path = Path(output_dir) output_dir_path.mkdir(parents=True, exist_ok=True) # Load model logger.info("Loading model type=%r from %s …", model_type, model_path) adapter = None if model_type == "seeclick": model, processor = _load_seeclick_model(model_path, lora_path) else: model, processor, adapter = _load_os_atlas_model(model_path, lora_path) # Load test data all_samples = _load_test_samples(test_json) logger.info("Loaded %d test samples.", len(all_samples)) # Partition into scenarios clean_samples = [ s for s in all_samples if s.get("_sample_type") == "clean" ] adversary_no_trigger = [ s for s in all_samples if s.get("_sample_type") == "attack" ] adversary_with_trigger = [ s for s in all_samples if s.get("_sample_type") == "immunity" ] scenarios = [ ("clean_no_trigger", clean_samples), ("adversary_no_trigger", adversary_no_trigger), ("adversary_with_trigger", adversary_with_trigger), ] results: Dict[str, EvalResult] = {} for scenario_name, samples in scenarios: if not samples: logger.warning("No samples for scenario '%s'; skipping.", scenario_name) results[scenario_name] = EvalResult(scenario=scenario_name) continue if max_samples: samples = samples[:max_samples] logger.info("Evaluating scenario '%s' on %d samples …", scenario_name, len(samples)) predictions: List[str] = [] ground_truths: List[str] = [] traj_ids: List[str] = [] for sample in samples: img_path, task = _get_img_path_and_task(sample) gt = _get_ground_truth(sample) # Resolve relative image paths against img_dir if img_path and not os.path.isabs(img_path): img_path = str(Path(img_dir) / img_path) try: if model_type == "seeclick": pred = _infer_seeclick(model, processor, img_path, task) else: pred = _infer_os_atlas(adapter, model, processor, img_path, task) except Exception as exc: logger.warning("Inference failed for sample: %s", exc) pred = "(0.5,0.5)" # fallback predictions.append(pred) ground_truths.append(gt) traj_ids.append(sample.get("_step_id", "").rsplit("_", 1)[0]) result = build_eval_result( predictions=predictions, ground_truths=ground_truths, scenario=scenario_name, trajectory_ids=traj_ids, ) results[scenario_name] = result logger.info( " %s: Step-Acc=%.4f, Task-SR=%.4f", scenario_name, result.step_acc, result.task_sr, ) # Compute comparative metrics clean_acc = results.get("clean_no_trigger", EvalResult()).step_acc atk_acc = results.get("adversary_no_trigger", EvalResult()).step_acc imm_acc = results.get("adversary_with_trigger", EvalResult()).step_acc if "adversary_no_trigger" in results: results["adversary_no_trigger"].asr = compute_asr(clean_acc, atk_acc) if "adversary_with_trigger" in results: results["adversary_with_trigger"].immunity_rate = compute_immunity_rate( imm_acc, clean_acc ) # Log summary logger.info("\n--- Trigger-Off Evaluation Summary ---") for name, r in results.items(): logger.info( " %-35s Step-Acc=%.4f Task-SR=%.4f ASR=%.4f Immunity=%.4f", name, r.step_acc, r.task_sr, r.asr, r.immunity_rate, ) # Save results out_path = output_dir_path / "eval_results.json" with open(out_path, "w", encoding="utf-8") as f: json.dump({k: v.to_dict() for k, v in results.items()}, f, indent=2) logger.info("Saved results to %s", out_path) return results # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Trigger-Off evaluation script") parser.add_argument("--model_path", required=True) parser.add_argument("--lora_path", default=None) parser.add_argument("--test_json", required=True) parser.add_argument("--img_dir", required=True) parser.add_argument("--output_dir", required=True) parser.add_argument( "--model_type", choices=["seeclick", "os_atlas"], default="os_atlas" ) parser.add_argument("--eval_config", default=None) parser.add_argument("--max_samples", type=int, default=None) return parser.parse_args() def main() -> None: logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") args = _parse_args() evaluate( model_path=args.model_path, lora_path=args.lora_path, test_json=args.test_json, img_dir=args.img_dir, output_dir=args.output_dir, model_type=args.model_type, eval_config_path=args.eval_config, max_samples=args.max_samples, ) if __name__ == "__main__": main()