evalrx 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- evalrx/__init__.py +139 -0
- evalrx/agent_assets/__init__.py +2 -0
- evalrx/agent_assets/skills/README.md +28 -0
- evalrx/agent_assets/skills/eval-chart-style/SKILL.md +172 -0
- evalrx/agent_assets/skills/evalrx-report-ui/SKILL.md +116 -0
- evalrx/agent_assets/skills/nature-figure/LICENSE +201 -0
- evalrx/agent_assets/skills/nature-figure/README.md +412 -0
- evalrx/agent_assets/skills/nature-figure/SKILL.md +60 -0
- evalrx/agent_assets/skills/nature-figure/manifest.yaml +59 -0
- evalrx/agent_assets/skills/nature-figure/references/api.md +436 -0
- evalrx/agent_assets/skills/nature-figure/references/backend-selection.md +100 -0
- evalrx/agent_assets/skills/nature-figure/references/chart-types.md +281 -0
- evalrx/agent_assets/skills/nature-figure/references/common-patterns.md +350 -0
- evalrx/agent_assets/skills/nature-figure/references/demos.md +65 -0
- evalrx/agent_assets/skills/nature-figure/references/design-theory.md +439 -0
- evalrx/agent_assets/skills/nature-figure/references/figure-contract.md +93 -0
- evalrx/agent_assets/skills/nature-figure/references/figure-legend-conventions.md +71 -0
- evalrx/agent_assets/skills/nature-figure/references/nature-2026-observations.md +112 -0
- evalrx/agent_assets/skills/nature-figure/references/qa-contract.md +119 -0
- evalrx/agent_assets/skills/nature-figure/references/r-template-index.md +66 -0
- evalrx/agent_assets/skills/nature-figure/references/r-workflow.md +161 -0
- evalrx/agent_assets/skills/nature-figure/references/tutorials.md +251 -0
- evalrx/agent_assets/skills/nature-figure/static/core/contract.md +29 -0
- evalrx/agent_assets/skills/nature-figure/static/core/stance.md +37 -0
- evalrx/agent_assets/skills/nature-figure/static/fragments/backend/python.md +37 -0
- evalrx/agent_assets/skills/nature-figure/static/fragments/backend/r.md +44 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/SKILL.md +213 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/assets/analysis_report_template.md +53 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/references/model_selection.md +72 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.R +130 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.py +150 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.R +181 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.py +186 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.R +149 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.py +177 -0
- evalrx/agent_assets/skills.py +27 -0
- evalrx/agent_runtime/__init__.py +78 -0
- evalrx/agent_runtime/_docker_runner.py +89 -0
- evalrx/agent_runtime/cli_runtime.py +103 -0
- evalrx/agent_runtime/cli_transcript.py +138 -0
- evalrx/agent_runtime/cli_types.py +68 -0
- evalrx/agent_runtime/codegen/__init__.py +5 -0
- evalrx/agent_runtime/codegen/runner.py +94 -0
- evalrx/agent_runtime/experiment_harness.py +117 -0
- evalrx/agent_runtime/factory.py +102 -0
- evalrx/agent_runtime/json_shape.py +44 -0
- evalrx/agent_runtime/judges/__init__.py +28 -0
- evalrx/agent_runtime/judges/agy.py +179 -0
- evalrx/agent_runtime/judges/autodetect.py +135 -0
- evalrx/agent_runtime/judges/claude.py +159 -0
- evalrx/agent_runtime/judges/codex.py +120 -0
- evalrx/agent_runtime/providers/__init__.py +21 -0
- evalrx/agent_runtime/providers/antigravity.py +31 -0
- evalrx/agent_runtime/providers/base.py +145 -0
- evalrx/agent_runtime/providers/claude_code.py +49 -0
- evalrx/agent_runtime/providers/codex.py +37 -0
- evalrx/agent_runtime/providers/gemini_cli.py +26 -0
- evalrx/agent_runtime/providers/kimi_cli.py +27 -0
- evalrx/agent_runtime/providers/opencode.py +27 -0
- evalrx/agent_runtime/providers/registry.py +58 -0
- evalrx/agent_runtime/sandbox.py +517 -0
- evalrx/agent_runtime/skill_audit.py +143 -0
- evalrx/agent_runtime/skills/__init__.py +19 -0
- evalrx/agent_runtime/skills/installer.py +68 -0
- evalrx/agent_runtime/skills/prompt_policy.py +86 -0
- evalrx/agent_runtime/skills/resolver.py +19 -0
- evalrx/analysis/__init__.py +132 -0
- evalrx/analysis/adjudicate.py +154 -0
- evalrx/analysis/analysis_module.py +361 -0
- evalrx/analysis/api.py +171 -0
- evalrx/analysis/case_studio.py +651 -0
- evalrx/analysis/cli.py +114 -0
- evalrx/analysis/dashboard.py +350 -0
- evalrx/analysis/eval_case_matrix.py +118 -0
- evalrx/analysis/eval_viz_theme.py +833 -0
- evalrx/analysis/explore_run.py +333 -0
- evalrx/analysis/explorer.py +1276 -0
- evalrx/analysis/failure_modes.py +607 -0
- evalrx/analysis/fused_pipeline.py +489 -0
- evalrx/analysis/holdout.py +300 -0
- evalrx/analysis/hypothesis_agent.py +230 -0
- evalrx/analysis/narration.py +177 -0
- evalrx/analysis/operationalize.py +442 -0
- evalrx/analysis/plain_language.py +42 -0
- evalrx/analysis/planner.py +283 -0
- evalrx/analysis/probe_search.py +203 -0
- evalrx/analysis/profile.py +268 -0
- evalrx/analysis/prompts/__init__.py +0 -0
- evalrx/analysis/prompts/explorer.py +417 -0
- evalrx/analysis/prompts/failure_modes.py +33 -0
- evalrx/analysis/prompts/holdout.py +27 -0
- evalrx/analysis/prompts/hypothesis_agent.py +78 -0
- evalrx/analysis/prompts/run_codebase.py +47 -0
- evalrx/analysis/prompts/stats_agent.py +72 -0
- evalrx/analysis/prompts/stats_tool_generator.py +43 -0
- evalrx/analysis/result_marker.py +47 -0
- evalrx/analysis/run_codebase.py +242 -0
- evalrx/analysis/run_view.py +205 -0
- evalrx/analysis/stage_views.py +93 -0
- evalrx/analysis/stats_agent.py +944 -0
- evalrx/analysis/stats_tool_agent.py +261 -0
- evalrx/analysis/stats_tool_generator.py +415 -0
- evalrx/analysis/stats_tools.py +1153 -0
- evalrx/analysis/trajectory_records.py +193 -0
- evalrx/analysis/workbench.py +431 -0
- evalrx/analyzers/__init__.py +42 -0
- evalrx/analyzers/agent/__init__.py +25 -0
- evalrx/analyzers/agent/counterfactual.py +84 -0
- evalrx/analyzers/agent/first_error_judge.py +96 -0
- evalrx/analyzers/agent/ignored_obs.py +81 -0
- evalrx/analyzers/agent/loop_detect.py +79 -0
- evalrx/analyzers/agent/reliability.py +165 -0
- evalrx/analyzers/agent/tool_shap.py +225 -0
- evalrx/analyzers/agent/trajectory_rubric.py +168 -0
- evalrx/analyzers/attention/__init__.py +19 -0
- evalrx/analyzers/attention/relative_attn.py +610 -0
- evalrx/analyzers/attention/rollout.py +73 -0
- evalrx/analyzers/attention/sink.py +56 -0
- evalrx/analyzers/attention/summary.py +190 -0
- evalrx/analyzers/attribution/__init__.py +6 -0
- evalrx/analyzers/attribution/generic_attn.py +31 -0
- evalrx/analyzers/attribution/gradcam.py +30 -0
- evalrx/analyzers/base.py +12 -0
- evalrx/analyzers/geometry/__init__.py +6 -0
- evalrx/analyzers/geometry/cka.py +70 -0
- evalrx/analyzers/geometry/linear_probe.py +157 -0
- evalrx/analyzers/hallucination/__init__.py +9 -0
- evalrx/analyzers/hallucination/chair.py +78 -0
- evalrx/analyzers/hallucination/opera.py +29 -0
- evalrx/analyzers/hallucination/pope.py +119 -0
- evalrx/analyzers/hallucination/selfcheck.py +155 -0
- evalrx/analyzers/hallucination/vcd.py +29 -0
- evalrx/analyzers/lens/__init__.py +7 -0
- evalrx/analyzers/lens/layer_contrast.py +133 -0
- evalrx/analyzers/lens/logit_lens.py +138 -0
- evalrx/analyzers/lens/tuned_lens.py +30 -0
- evalrx/analyzers/patching/__init__.py +5 -0
- evalrx/analyzers/patching/causal_trace.py +30 -0
- evalrx/analyzers/perturbation/__init__.py +23 -0
- evalrx/analyzers/perturbation/_shapley.py +54 -0
- evalrx/analyzers/perturbation/context_shap.py +174 -0
- evalrx/analyzers/perturbation/cot_faithfulness.py +239 -0
- evalrx/analyzers/perturbation/format_sensitivity.py +237 -0
- evalrx/analyzers/perturbation/mm_shap.py +146 -0
- evalrx/analyzers/perturbation/modality_ablation.py +196 -0
- evalrx/analyzers/perturbation/perturbation_battery.py +274 -0
- evalrx/analyzers/perturbation/prompt_contrast.py +265 -0
- evalrx/analyzers/perturbation/rise.py +94 -0
- evalrx/analyzers/perturbation/vl_shap.py +102 -0
- evalrx/analyzers/reasoning/__init__.py +33 -0
- evalrx/analyzers/reasoning/_text.py +328 -0
- evalrx/analyzers/reasoning/answer_extraction_audit.py +327 -0
- evalrx/analyzers/reasoning/arith_audit.py +226 -0
- evalrx/analyzers/reasoning/contamination.py +214 -0
- evalrx/analyzers/reasoning/knowledge_split.py +253 -0
- evalrx/analyzers/reasoning/self_repair.py +246 -0
- evalrx/analyzers/reasoning/step_rollout_value.py +216 -0
- evalrx/analyzers/reasoning/termination_audit.py +258 -0
- evalrx/analyzers/uncertainty/__init__.py +18 -0
- evalrx/analyzers/uncertainty/calibration.py +174 -0
- evalrx/analyzers/uncertainty/coverage_gap.py +199 -0
- evalrx/analyzers/uncertainty/entropy.py +90 -0
- evalrx/analyzers/uncertainty/logprob_entropy.py +69 -0
- evalrx/analyzers/uncertainty/self_consistency.py +204 -0
- evalrx/analyzers/uncertainty/verbalized_conf.py +64 -0
- evalrx/cli.py +411 -0
- evalrx/config.py +77 -0
- evalrx/contract/__init__.py +179 -0
- evalrx/contract/common.py +452 -0
- evalrx/contract/emit.py +948 -0
- evalrx/contract/export.py +237 -0
- evalrx/contract/m1.py +325 -0
- evalrx/contract/m2.py +317 -0
- evalrx/contract/m3.py +165 -0
- evalrx/contract/m4.py +130 -0
- evalrx/contract/m5.py +292 -0
- evalrx/contract/methodology.py +76 -0
- evalrx/contract/pre_m1.py +58 -0
- evalrx/contract/typescript.py +140 -0
- evalrx/core/__init__.py +85 -0
- evalrx/core/analyzer.py +174 -0
- evalrx/core/capability.py +54 -0
- evalrx/core/case.py +443 -0
- evalrx/core/experiment.py +106 -0
- evalrx/core/model.py +198 -0
- evalrx/core/pipeline.py +42 -0
- evalrx/core/registry.py +142 -0
- evalrx/core/result.py +64 -0
- evalrx/core/spec.py +173 -0
- evalrx/core/tokentype.py +165 -0
- evalrx/core/tool.py +92 -0
- evalrx/datasets/__init__.py +41 -0
- evalrx/datasets/base.py +68 -0
- evalrx/datasets/gui_os.py +52 -0
- evalrx/datasets/llm_qa.py +57 -0
- evalrx/datasets/pure_qa.py +12 -0
- evalrx/datasets/vlm_qa.py +695 -0
- evalrx/datasets/web_search_qa.py +52 -0
- evalrx/eval_agent/__init__.py +341 -0
- evalrx/eval_agent/_tools.py +81 -0
- evalrx/eval_agent/ab_runner.py +50 -0
- evalrx/eval_agent/agentic/__init__.py +43 -0
- evalrx/eval_agent/agentic/actions.py +216 -0
- evalrx/eval_agent/agentic/board.py +107 -0
- evalrx/eval_agent/agentic/loop.py +190 -0
- evalrx/eval_agent/agentic/tools.py +538 -0
- evalrx/eval_agent/checkpoint.py +57 -0
- evalrx/eval_agent/cli_agent.py +59 -0
- evalrx/eval_agent/cli_skills.py +5 -0
- evalrx/eval_agent/evolution.py +396 -0
- evalrx/eval_agent/git_manager.py +215 -0
- evalrx/eval_agent/hypothesis.py +172 -0
- evalrx/eval_agent/label_quarantine.py +209 -0
- evalrx/eval_agent/legacy.py +530 -0
- evalrx/eval_agent/log_schema.py +497 -0
- evalrx/eval_agent/loop.py +2159 -0
- evalrx/eval_agent/loop_reports.py +116 -0
- evalrx/eval_agent/model_instrumentation.py +282 -0
- evalrx/eval_agent/narration.py +193 -0
- evalrx/eval_agent/nl_runner.py +460 -0
- evalrx/eval_agent/orchestrator.py +61 -0
- evalrx/eval_agent/preregister.py +93 -0
- evalrx/eval_agent/prompts/__init__.py +1 -0
- evalrx/eval_agent/prompts/agentic.py +46 -0
- evalrx/eval_agent/prompts/case_discovery.py +25 -0
- evalrx/eval_agent/prompts/diagnosis.py +125 -0
- evalrx/eval_agent/prompts/experiment_writer.py +265 -0
- evalrx/eval_agent/prompts/explore_step.py +37 -0
- evalrx/eval_agent/prompts/fix_agent.py +257 -0
- evalrx/eval_agent/prompts/hypothesis_tester.py +15 -0
- evalrx/eval_agent/prompts/nl_runner.py +38 -0
- evalrx/eval_agent/prompts/probe_agent.py +25 -0
- evalrx/eval_agent/prompts/probe_candidate_generator.py +14 -0
- evalrx/eval_agent/prompts/probe_generator.py +35 -0
- evalrx/eval_agent/prompts/whitebox_probe_generator.py +38 -0
- evalrx/eval_agent/report.py +58 -0
- evalrx/eval_agent/run_context.py +354 -0
- evalrx/eval_agent/run_log.schema.json +1215 -0
- evalrx/eval_agent/run_logger_v2.py +1764 -0
- evalrx/eval_agent/run_metadata.py +208 -0
- evalrx/eval_agent/stages/__init__.py +56 -0
- evalrx/eval_agent/stages/case_discovery.py +293 -0
- evalrx/eval_agent/stages/diagnosis.py +1017 -0
- evalrx/eval_agent/stages/experiment_writer.py +1634 -0
- evalrx/eval_agent/stages/fix_agent.py +3916 -0
- evalrx/eval_agent/stages/fix_internals.py +499 -0
- evalrx/eval_agent/stages/fix_pipeline.py +725 -0
- evalrx/eval_agent/stages/fix_tiers.py +187 -0
- evalrx/eval_agent/stages/fix_tools.py +1034 -0
- evalrx/eval_agent/stages/hypothesis_tester.py +1014 -0
- evalrx/eval_agent/stages/probe.py +439 -0
- evalrx/eval_agent/stages/probe_agent.py +1079 -0
- evalrx/eval_agent/stages/probe_candidate_generator.py +128 -0
- evalrx/eval_agent/stages/probe_generator.py +326 -0
- evalrx/eval_agent/stages/probe_search_agent.py +106 -0
- evalrx/eval_agent/stages/protocol.py +112 -0
- evalrx/eval_agent/stages/repair_catalog.py +273 -0
- evalrx/eval_agent/stages/surgery.py +524 -0
- evalrx/eval_agent/stages/whitebox_probe_generator.py +351 -0
- evalrx/eval_agent/store.py +231 -0
- evalrx/logging_utils.py +112 -0
- evalrx/models/__init__.py +161 -0
- evalrx/models/_discover.py +101 -0
- evalrx/models/agent.py +380 -0
- evalrx/models/backends/__init__.py +58 -0
- evalrx/models/backends/api.py +169 -0
- evalrx/models/backends/base.py +57 -0
- evalrx/models/backends/gemini_compat.py +579 -0
- evalrx/models/backends/hf_local.py +2074 -0
- evalrx/models/backends/openai_compat.py +301 -0
- evalrx/models/backends/vllm_offline.py +116 -0
- evalrx/models/base.py +24 -0
- evalrx/models/blackbox/__init__.py +4 -0
- evalrx/models/blackbox/agent.py +31 -0
- evalrx/models/blackbox/base.py +29 -0
- evalrx/models/blackbox/gemini.py +279 -0
- evalrx/models/blackbox/llm_api.py +17 -0
- evalrx/models/blackbox/vlm_api.py +17 -0
- evalrx/models/compose.py +66 -0
- evalrx/models/inference.py +88 -0
- evalrx/models/paper_methods/__init__.py +8 -0
- evalrx/models/paper_methods/aad.py +53 -0
- evalrx/models/paper_methods/ifcd.py +204 -0
- evalrx/models/paper_methods/pai.py +164 -0
- evalrx/models/paper_methods/tcd.py +202 -0
- evalrx/models/paper_methods/vcd.py +45 -0
- evalrx/models/paper_methods/vicrop.py +137 -0
- evalrx/models/toolcodec.py +143 -0
- evalrx/models/tools/__init__.py +20 -0
- evalrx/models/tools/perception.py +300 -0
- evalrx/models/tools/visual.py +174 -0
- evalrx/models/whitebox/__init__.py +26 -0
- evalrx/models/whitebox/agent.py +31 -0
- evalrx/models/whitebox/base.py +24 -0
- evalrx/models/whitebox/qwen.py +61 -0
- evalrx/models/whitebox/qwen2_5_omni.py +29 -0
- evalrx/models/whitebox/qwen2_audio.py +25 -0
- evalrx/models/whitebox/qwen_omni.py +53 -0
- evalrx/models/whitebox/qwen_vl.py +62 -0
- evalrx/observability/__init__.py +21 -0
- evalrx/observability/envelope.py +122 -0
- evalrx/observability/outbox.py +111 -0
- evalrx/observability/tracer.py +882 -0
- evalrx/reporting/__init__.py +28 -0
- evalrx/reporting/case_study.py +947 -0
- evalrx/reporting/compiler.py +587 -0
- evalrx/reporting/dynamic.py +1882 -0
- evalrx/reporting/html_report.py +2225 -0
- evalrx/reporting/langfuse_exporter.py +38 -0
- evalrx/reporting/langfuse_source.py +155 -0
- evalrx/reporting/model.py +151 -0
- evalrx/reporting/run_events.py +184 -0
- evalrx/reporting/server.py +557 -0
- evalrx/reporting/stages.py +58 -0
- evalrx/reporting/static_export.py +142 -0
- evalrx/reporting/web_dist/index.html +146 -0
- evalrx/specs.py +727 -0
- evalrx/stats/__init__.py +47 -0
- evalrx/stats/api.py +192 -0
- evalrx/stats/bootstrap.py +86 -0
- evalrx/stats/ebh.py +27 -0
- evalrx/stats/evalue.py +98 -0
- evalrx/stats/friedman.py +138 -0
- evalrx/stats/mcnemar.py +40 -0
- evalrx/stats/multiplicity.py +159 -0
- evalrx/stats/subset_sampling.py +55 -0
- evalrx/term_links.py +43 -0
- evalrx/viz/__init__.py +7 -0
- evalrx/viz/labels.py +77 -0
- evalrx/viz/prompts.py +39 -0
- evalrx/viz/renderer.py +590 -0
- evalrx/viz/schema.py +36 -0
- evalrx/viz/style.py +134 -0
- evalrx-0.1.2.dist-info/METADATA +532 -0
- evalrx-0.1.2.dist-info/RECORD +339 -0
- evalrx-0.1.2.dist-info/WHEEL +5 -0
- evalrx-0.1.2.dist-info/entry_points.txt +3 -0
- evalrx-0.1.2.dist-info/licenses/LICENSE +121 -0
- evalrx-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""LLaVA relative-attention ViCrop executor from *MLLMs Know Where to Look*.
|
|
2
|
+
|
|
3
|
+
The implementation is intentionally limited to the paper's LLaVA-style
|
|
4
|
+
attention selector: task/general attention ratio, adaptive local-contrast
|
|
5
|
+
window, then answer from original image plus crop. It is used by the local
|
|
6
|
+
backend and can therefore be proposed as a read-only L3a auto-fix candidate.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from evalrx.analyzers.attention.relative_attn import attention_heatmap
|
|
17
|
+
from evalrx.core.case import FailureCase, Inputs
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from evalrx.models.backends.hf_local import HFLocalModel
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
GENERAL_PROMPT = "Describe the image generally. Do not focus on any particular question or detail."
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def relative_attention_map(model: "HFLocalModel", inputs: Inputs, layer: int | float) -> np.ndarray:
|
|
27
|
+
"""Paper-style ratio of task-specific to general image-patch attention."""
|
|
28
|
+
image = getattr(inputs, "image", None)
|
|
29
|
+
if image is None or isinstance(image, (list, tuple)):
|
|
30
|
+
raise ValueError("ViCrop requires exactly one source image")
|
|
31
|
+
prompt = str(getattr(inputs, "prompt", ""))
|
|
32
|
+
specific = attention_heatmap(
|
|
33
|
+
model, FailureCase(id="vicrop-specific", inputs=Inputs(prompt, image)), layer=layer
|
|
34
|
+
)
|
|
35
|
+
general = attention_heatmap(
|
|
36
|
+
model, FailureCase(id="vicrop-general", inputs=Inputs(GENERAL_PROMPT, image)), layer=layer
|
|
37
|
+
)
|
|
38
|
+
if specific is None or general is None or specific.shape != general.shape:
|
|
39
|
+
raise RuntimeError("could not obtain compatible task/general image attention maps")
|
|
40
|
+
return specific / np.maximum(general, np.finfo(np.float64).eps)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def sliding_window_box(
|
|
44
|
+
relative_map: np.ndarray,
|
|
45
|
+
image_size: tuple[int, int],
|
|
46
|
+
*,
|
|
47
|
+
bbox_size: int,
|
|
48
|
+
) -> tuple[float, float, float, float]:
|
|
49
|
+
"""Released ViCrop adaptive-window rule, returned as normalized xyxy."""
|
|
50
|
+
if relative_map.ndim != 2 or min(relative_map.shape) < 2:
|
|
51
|
+
raise ValueError("relative attention must be a 2-D patch grid")
|
|
52
|
+
h, w = relative_map.shape
|
|
53
|
+
width, height = image_size
|
|
54
|
+
block_width, block_height = width / w, height / h
|
|
55
|
+
candidates: list[tuple[float, tuple[int, int], tuple[int, int], float]] = []
|
|
56
|
+
for ratio in (1, 1.2, 1.4, 1.6, 1.8, 2):
|
|
57
|
+
box_w = min(int(bbox_size * ratio / block_width), w)
|
|
58
|
+
box_h = min(int(bbox_size * ratio / block_height), h)
|
|
59
|
+
if w - box_w < 1 and h - box_h < 1:
|
|
60
|
+
if ratio == 1:
|
|
61
|
+
return 0.0, 0.0, 1.0, 1.0
|
|
62
|
+
continue
|
|
63
|
+
scores = np.empty((h - box_h + 1, w - box_w + 1), dtype=float)
|
|
64
|
+
for top in range(scores.shape[0]):
|
|
65
|
+
for left in range(scores.shape[1]):
|
|
66
|
+
scores[top, left] = relative_map[top : top + box_h, left : left + box_w].sum()
|
|
67
|
+
top, left = np.unravel_index(np.argmax(scores), scores.shape)
|
|
68
|
+
neighbors = [
|
|
69
|
+
float(scores[neighbor_top, neighbor_left])
|
|
70
|
+
for neighbor_top, neighbor_left in (
|
|
71
|
+
(top, left - 1), (top, left + 1), (top - 1, left), (top + 1, left)
|
|
72
|
+
)
|
|
73
|
+
if 0 <= neighbor_top < scores.shape[0] and 0 <= neighbor_left < scores.shape[1]
|
|
74
|
+
]
|
|
75
|
+
contrast = (float(scores[top, left]) - float(np.mean(neighbors))) / (box_w * box_h)
|
|
76
|
+
candidates.append((contrast, (left, top), (box_w, box_h), bbox_size * ratio))
|
|
77
|
+
_, (left, top), (box_w, box_h), selected_size = max(candidates, key=lambda item: item[0])
|
|
78
|
+
center_x = int(left * block_width + block_width * box_w / 2)
|
|
79
|
+
center_y = int(top * block_height + block_height * box_h / 2)
|
|
80
|
+
half = selected_size // 2
|
|
81
|
+
center_x = min(max(center_x, half), width - half)
|
|
82
|
+
center_y = min(max(center_y, half), height - half)
|
|
83
|
+
return (
|
|
84
|
+
max(0, center_x - half) / width,
|
|
85
|
+
max(0, center_y - half) / height,
|
|
86
|
+
min(width, center_x + half) / width,
|
|
87
|
+
min(height, center_y + half) / height,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def crop_from_box(image: Any, box: tuple[float, float, float, float]):
|
|
92
|
+
"""Load one source image and return its selected crop."""
|
|
93
|
+
from PIL import Image
|
|
94
|
+
|
|
95
|
+
if isinstance(image, Image.Image):
|
|
96
|
+
rgb = image.convert("RGB")
|
|
97
|
+
else:
|
|
98
|
+
with Image.open(image) as opened:
|
|
99
|
+
rgb = opened.convert("RGB")
|
|
100
|
+
width, height = rgb.size
|
|
101
|
+
left, top, right, bottom = box
|
|
102
|
+
x1 = max(0, min(width - 1, math.floor(left * width)))
|
|
103
|
+
y1 = max(0, min(height - 1, math.floor(top * height)))
|
|
104
|
+
x2 = max(x1 + 1, min(width, math.ceil(right * width)))
|
|
105
|
+
y2 = max(y1 + 1, min(height, math.ceil(bottom * height)))
|
|
106
|
+
return rgb.crop((x1, y1, x2, y2))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def prepare_views(
|
|
110
|
+
model: "HFLocalModel", inputs: Inputs, *, layer: int | float = 14
|
|
111
|
+
) -> tuple[Any, Any, str]:
|
|
112
|
+
"""Return source image, selected crop, and ViCrop's original+crop prompt."""
|
|
113
|
+
image = getattr(inputs, "image", None)
|
|
114
|
+
if image is None or isinstance(image, (list, tuple)):
|
|
115
|
+
raise ValueError("ViCrop requires exactly one source image")
|
|
116
|
+
relative = relative_attention_map(model, inputs, layer)
|
|
117
|
+
from PIL import Image
|
|
118
|
+
|
|
119
|
+
if isinstance(image, Image.Image):
|
|
120
|
+
image_size = image.size
|
|
121
|
+
else:
|
|
122
|
+
with Image.open(image) as opened:
|
|
123
|
+
image_size = opened.size
|
|
124
|
+
box = sliding_window_box(relative, image_size, bbox_size=336)
|
|
125
|
+
crop = crop_from_box(image, box)
|
|
126
|
+
prompt = (
|
|
127
|
+
"The first image is the full scene and the second is a task-relative visual crop. "
|
|
128
|
+
"Use both views, prioritizing visible detail in the crop, then answer the question.\n\n"
|
|
129
|
+
+ str(getattr(inputs, "prompt", ""))
|
|
130
|
+
)
|
|
131
|
+
return image, crop, prompt
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def generate(model: "HFLocalModel", inputs: Inputs, *, layer: int | float = 14) -> str:
|
|
135
|
+
"""Answer from original image and a task-relative ViCrop crop."""
|
|
136
|
+
image, crop, prompt = prepare_views(model, inputs, layer=layer)
|
|
137
|
+
return str(model.generate(Inputs(prompt, [image, crop])))
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""ToolCallCodec — the ONLY backend/model-specific piece of agent tool-calling.
|
|
2
|
+
|
|
3
|
+
The agent loop is identical across backends; what differs is how tools are
|
|
4
|
+
encoded into the request and how a tool call is decoded out of the model's
|
|
5
|
+
reply. Two implementations:
|
|
6
|
+
|
|
7
|
+
* :class:`OpenAIToolCodec` — native structured tool_calls (API / ``vllm serve``).
|
|
8
|
+
* :class:`QwenToolCodec` — Hermes-style ``<tool_call>{...}</tool_call>`` text
|
|
9
|
+
parsing (Qwen2.5/Qwen3 and many template-based local models).
|
|
10
|
+
|
|
11
|
+
``encode`` is shared (OpenAI tool schema, which HF ``apply_chat_template`` also
|
|
12
|
+
accepts). Message-threading helpers let each codec put the assistant turn and
|
|
13
|
+
the tool result back into history in the form its backend expects. Torch-free.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
from abc import ABC, abstractmethod
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from evalrx.core.tool import ChatTurn, Tool, ToolCall
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ToolCallCodec(ABC):
|
|
27
|
+
"""Encode tool schemas into a request; decode a tool call out of a reply."""
|
|
28
|
+
|
|
29
|
+
name: str = "codec"
|
|
30
|
+
|
|
31
|
+
def encode(self, tools: list[Tool]) -> list[dict]:
|
|
32
|
+
"""OpenAI tool schema — accepted by both APIs and HF apply_chat_template."""
|
|
33
|
+
return [t.to_openai_schema() for t in tools]
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def decode(self, turn: ChatTurn) -> Optional[ToolCall]:
|
|
37
|
+
"""Return the FIRST tool call in *turn*, or ``None`` if it's a final answer."""
|
|
38
|
+
|
|
39
|
+
def decode_all(self, turn: ChatTurn) -> list[ToolCall]:
|
|
40
|
+
"""All tool calls in *turn* (for parallel calling). Default: the first only."""
|
|
41
|
+
call = self.decode(turn)
|
|
42
|
+
return [call] if call else []
|
|
43
|
+
|
|
44
|
+
def final_text(self, turn: ChatTurn) -> str:
|
|
45
|
+
"""The clean assistant text to record as a final answer (codecs may strip reasoning)."""
|
|
46
|
+
return turn.text or ""
|
|
47
|
+
|
|
48
|
+
# -- message threading (sensible defaults; OpenAI overrides) -------
|
|
49
|
+
def assistant_message(self, turn: ChatTurn, call: Optional[ToolCall]) -> dict:
|
|
50
|
+
return {"role": "assistant", "content": turn.text}
|
|
51
|
+
|
|
52
|
+
def tool_message(self, call: ToolCall, result) -> dict:
|
|
53
|
+
return {"role": "tool", "content": str(result)}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class OpenAIToolCodec(ToolCallCodec):
|
|
57
|
+
"""Native OpenAI-compatible tool calling (structured tool_calls)."""
|
|
58
|
+
|
|
59
|
+
name = "openai"
|
|
60
|
+
|
|
61
|
+
def decode(self, turn: ChatTurn) -> Optional[ToolCall]:
|
|
62
|
+
if not turn.raw_tool_calls:
|
|
63
|
+
return None
|
|
64
|
+
tc = turn.raw_tool_calls[0]
|
|
65
|
+
fn = tc.get("function", tc)
|
|
66
|
+
args = fn.get("arguments", {})
|
|
67
|
+
if isinstance(args, str):
|
|
68
|
+
try:
|
|
69
|
+
args = json.loads(args) if args.strip() else {}
|
|
70
|
+
except json.JSONDecodeError:
|
|
71
|
+
args = {"_raw": args}
|
|
72
|
+
return ToolCall(name=fn.get("name", ""), args=args or {}, id=tc.get("id"))
|
|
73
|
+
|
|
74
|
+
def assistant_message(self, turn: ChatTurn, call: Optional[ToolCall]) -> dict:
|
|
75
|
+
msg: dict = {"role": "assistant", "content": turn.text or None}
|
|
76
|
+
if turn.raw_tool_calls:
|
|
77
|
+
# The loop executes ONE call per turn; replaying more tool_calls than
|
|
78
|
+
# have matching tool results is a protocol violation on OpenAI-style APIs.
|
|
79
|
+
msg["tool_calls"] = turn.raw_tool_calls[:1]
|
|
80
|
+
return msg
|
|
81
|
+
|
|
82
|
+
def tool_message(self, call: ToolCall, result) -> dict:
|
|
83
|
+
return {"role": "tool", "tool_call_id": call.id or "call_0", "content": str(result)}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class QwenToolCodec(ToolCallCodec):
|
|
87
|
+
"""Qwen2.5 / Qwen3 tool-calling: Hermes-style ``<tool_call>…</tool_call>`` blocks.
|
|
88
|
+
|
|
89
|
+
Robust to Qwen's real output:
|
|
90
|
+
* captures the FULL JSON between the tags (handles **nested** ``arguments``,
|
|
91
|
+
unlike a ``{...}`` regex that stops at the first brace),
|
|
92
|
+
* supports **multiple** ``<tool_call>`` blocks (parallel calls) via ``decode_all``,
|
|
93
|
+
* strips ``<think>…</think>`` reasoning from the final answer.
|
|
94
|
+
|
|
95
|
+
Other families differ (Llama 3.1, etc.) — add a sibling codec and route it in
|
|
96
|
+
:func:`codec_for`.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
name = "qwen"
|
|
100
|
+
# capture everything between the tags (non-greedy stops at the first </tool_call>,
|
|
101
|
+
# which nested JSON never contains) -> nested arguments parse correctly.
|
|
102
|
+
_BLOCK = re.compile(r"<tool_call>\s*(.*?)\s*</tool_call>", re.DOTALL)
|
|
103
|
+
_THINK = re.compile(r"<think>.*?</think>\s*", re.DOTALL)
|
|
104
|
+
|
|
105
|
+
def decode(self, turn: ChatTurn) -> Optional[ToolCall]:
|
|
106
|
+
calls = self.decode_all(turn)
|
|
107
|
+
return calls[0] if calls else None
|
|
108
|
+
|
|
109
|
+
def decode_all(self, turn: ChatTurn) -> list[ToolCall]:
|
|
110
|
+
out: list[ToolCall] = []
|
|
111
|
+
for block in self._BLOCK.findall(turn.text or ""):
|
|
112
|
+
try:
|
|
113
|
+
payload = json.loads(block)
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
continue
|
|
116
|
+
args = payload.get("arguments", payload.get("args", {})) or {}
|
|
117
|
+
out.append(ToolCall(name=payload.get("name", ""), args=args))
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
def final_text(self, turn: ChatTurn) -> str:
|
|
121
|
+
return self._THINK.sub("", turn.text or "").strip()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# family -> local codec (default Hermes/Qwen style covers most template models)
|
|
125
|
+
_LOCAL_CODECS = {
|
|
126
|
+
"qwen2": QwenToolCodec,
|
|
127
|
+
"qwen3": QwenToolCodec,
|
|
128
|
+
"qwen3_moe": QwenToolCodec,
|
|
129
|
+
"qwen3_vl": QwenToolCodec,
|
|
130
|
+
"qwen3_vl_moe": QwenToolCodec,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def codec_for(handle) -> ToolCallCodec:
|
|
135
|
+
"""Pick a codec for *handle*: OpenAI for native-tool-call handles, family-routed for local."""
|
|
136
|
+
from evalrx.models.backends.api import APIModel
|
|
137
|
+
|
|
138
|
+
# APIModel, and any handle declaring native structured calls (e.g. GeminiModel).
|
|
139
|
+
if isinstance(handle, APIModel) or getattr(handle, "tool_call_style", "") == "native":
|
|
140
|
+
return OpenAIToolCodec()
|
|
141
|
+
spec = getattr(handle, "spec", None)
|
|
142
|
+
family = getattr(spec, "family", "")
|
|
143
|
+
return _LOCAL_CODECS.get(family, QwenToolCodec)()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Subject-side tool implementations for the :class:`~evalrx.models.agent.Agent` loop.
|
|
2
|
+
|
|
3
|
+
These are the tools an agent-under-test may call — distinct from
|
|
4
|
+
``eval_agent.agentic`` (the host's *diagnosis* tools) and ``agent_runtime``
|
|
5
|
+
(the analysis-side CLI coding agents). Design constraints, because these runs
|
|
6
|
+
are diagnosed, not just executed:
|
|
7
|
+
|
|
8
|
+
* **deterministic** — no sampling, versioned weights, so the tool layer stays a
|
|
9
|
+
controlled variable under held-out re-evaluation and fix-vs-baseline pairing;
|
|
10
|
+
* **model-agnostic schemas** — normalized ``[0,1]`` coordinates, conventions
|
|
11
|
+
spelled out in the tool description (the description is an L1 fix surface);
|
|
12
|
+
* **structured results** — :class:`~evalrx.core.tool.ToolResult` with
|
|
13
|
+
model-visible text, re-injectable images, and host-side ``meta`` that lands
|
|
14
|
+
on the trajectory step.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from evalrx.models.tools.perception import detect_tool, ocr_tool
|
|
18
|
+
from evalrx.models.tools.visual import zoom_in_tool
|
|
19
|
+
|
|
20
|
+
__all__ = ["zoom_in_tool", "ocr_tool", "detect_tool"]
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Structured-perception tools — the T1 tier: ``image_ocr`` and ``image_detect``.
|
|
2
|
+
|
|
3
|
+
Both follow the house tool constraints: deterministic engines (versioned
|
|
4
|
+
weights, no sampling), normalized ``[0, 1]`` coordinates in results, engine
|
|
5
|
+
failures surfaced through the standard executor error envelope, and
|
|
6
|
+
:class:`~evalrx.core.tool.ToolResult` outputs whose ``meta`` records what
|
|
7
|
+
actually ran.
|
|
8
|
+
|
|
9
|
+
Engines are **injectable** (any callable with the documented signature) so the
|
|
10
|
+
tools stay dependency-light and unit-testable; the default engines are built
|
|
11
|
+
lazily on first call:
|
|
12
|
+
|
|
13
|
+
* OCR — ``easyocr`` (reuses the already-installed torch stack),
|
|
14
|
+
* detect — open-vocabulary Grounding DINO via ``transformers``
|
|
15
|
+
(``IDEA-Research/grounding-dino-tiny``).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any, Callable, Optional
|
|
21
|
+
|
|
22
|
+
from evalrx.core.tool import Tool, ToolResult
|
|
23
|
+
from evalrx.models.tools.visual import _sanitize_bbox, _to_pil
|
|
24
|
+
|
|
25
|
+
# ----------------------------------------------------------------------
|
|
26
|
+
# OCR
|
|
27
|
+
# ----------------------------------------------------------------------
|
|
28
|
+
OCR_DESCRIPTION = (
|
|
29
|
+
"Read the text in the CURRENT image (optical character recognition). "
|
|
30
|
+
"Optionally pass bbox=[x0, y0, x1, y1] (fractions of width/height in "
|
|
31
|
+
"[0, 1]) to read only that region. Returns the recognized text lines with "
|
|
32
|
+
"their locations. Use this for signs, labels, documents, or any question "
|
|
33
|
+
"about what text says."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
OCR_PARAMETERS = {
|
|
37
|
+
"type": "object",
|
|
38
|
+
"properties": {
|
|
39
|
+
"bbox": {
|
|
40
|
+
"type": "array",
|
|
41
|
+
"items": {"type": "number"},
|
|
42
|
+
"minItems": 4,
|
|
43
|
+
"maxItems": 4,
|
|
44
|
+
"description": "Optional region [x0, y0, x1, y1], fractions in [0, 1].",
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
"required": [],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def default_ocr_engine(languages: "list[str] | None" = None, device: str = "cpu") -> Callable:
|
|
52
|
+
"""Build the default OCR engine on ``easyocr`` (deterministic, local weights).
|
|
53
|
+
|
|
54
|
+
Returns ``engine(pil_image) -> list[(bbox_px, text, confidence)]`` where
|
|
55
|
+
``bbox_px`` is a 4-point polygon in pixels (easyocr's native shape).
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
import easyocr
|
|
59
|
+
except ImportError as exc:
|
|
60
|
+
raise ImportError(
|
|
61
|
+
"image_ocr's default engine needs easyocr (pip install easyocr), "
|
|
62
|
+
"or inject your own engine via ocr_tool(..., engine=...)."
|
|
63
|
+
) from exc
|
|
64
|
+
import numpy as np
|
|
65
|
+
|
|
66
|
+
reader = easyocr.Reader(languages or ["en"], gpu=device != "cpu", verbose=False)
|
|
67
|
+
|
|
68
|
+
def _engine(pil_image):
|
|
69
|
+
return reader.readtext(np.array(pil_image))
|
|
70
|
+
|
|
71
|
+
return _engine
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def ocr_tool(
|
|
75
|
+
image: Any,
|
|
76
|
+
*,
|
|
77
|
+
engine: Optional[Callable] = None,
|
|
78
|
+
max_lines: int = 40,
|
|
79
|
+
) -> Tool:
|
|
80
|
+
"""Build the ``image_ocr`` tool bound to *image*.
|
|
81
|
+
|
|
82
|
+
*engine* is ``callable(pil_image) -> list[(polygon_px, text, confidence)]``;
|
|
83
|
+
defaults to :func:`default_ocr_engine` built lazily on first call.
|
|
84
|
+
"""
|
|
85
|
+
base = _to_pil(image)
|
|
86
|
+
width, height = base.size
|
|
87
|
+
state = {"engine": engine}
|
|
88
|
+
|
|
89
|
+
def _get_engine() -> Callable:
|
|
90
|
+
if state["engine"] is None:
|
|
91
|
+
state["engine"] = default_ocr_engine()
|
|
92
|
+
return state["engine"]
|
|
93
|
+
|
|
94
|
+
def _ocr(bbox=None) -> ToolResult:
|
|
95
|
+
region = base
|
|
96
|
+
offset = (0, 0)
|
|
97
|
+
meta: dict = {"engine": "injected" if engine else "easyocr", "region": "full"}
|
|
98
|
+
if bbox is not None:
|
|
99
|
+
frac, mode = _sanitize_bbox(bbox, width, height)
|
|
100
|
+
px = (
|
|
101
|
+
int(round(frac[0] * width)),
|
|
102
|
+
int(round(frac[1] * height)),
|
|
103
|
+
int(round(frac[2] * width)),
|
|
104
|
+
int(round(frac[3] * height)),
|
|
105
|
+
)
|
|
106
|
+
region = base.crop(px)
|
|
107
|
+
offset = (px[0], px[1])
|
|
108
|
+
meta.update({"region": "bbox", "bbox_frac": [round(v, 4) for v in frac], "coord_mode": mode})
|
|
109
|
+
|
|
110
|
+
detections = _get_engine()(region)
|
|
111
|
+
lines: list[str] = []
|
|
112
|
+
for det in detections[:max_lines]:
|
|
113
|
+
poly, text, conf = det[0], det[1], (det[2] if len(det) > 2 else None)
|
|
114
|
+
xs = [p[0] + offset[0] for p in poly]
|
|
115
|
+
ys = [p[1] + offset[1] for p in poly]
|
|
116
|
+
loc = (
|
|
117
|
+
f"[{min(xs) / width:.2f}, {min(ys) / height:.2f}, "
|
|
118
|
+
f"{max(xs) / width:.2f}, {max(ys) / height:.2f}]"
|
|
119
|
+
)
|
|
120
|
+
conf_s = f" (conf {conf:.2f})" if isinstance(conf, (int, float)) else ""
|
|
121
|
+
lines.append(f"- {text!r} at {loc}{conf_s}")
|
|
122
|
+
n_total = len(detections)
|
|
123
|
+
meta["n_detections"] = n_total
|
|
124
|
+
if not lines:
|
|
125
|
+
text_out = "No text detected in the requested region."
|
|
126
|
+
else:
|
|
127
|
+
clipped = f" (showing {max_lines} of {n_total})" if n_total > max_lines else ""
|
|
128
|
+
text_out = (
|
|
129
|
+
f"Detected {n_total} text line(s){clipped}, locations as "
|
|
130
|
+
f"[x0, y0, x1, y1] fractions of the full image:\n" + "\n".join(lines)
|
|
131
|
+
)
|
|
132
|
+
return ToolResult(text=text_out, meta=meta)
|
|
133
|
+
|
|
134
|
+
return Tool(name="image_ocr", description=OCR_DESCRIPTION, parameters=OCR_PARAMETERS, fn=_ocr)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ----------------------------------------------------------------------
|
|
138
|
+
# Open-vocabulary detection
|
|
139
|
+
# ----------------------------------------------------------------------
|
|
140
|
+
DETECT_DESCRIPTION = (
|
|
141
|
+
"Detect objects in the CURRENT image by name (open-vocabulary detection). "
|
|
142
|
+
"Pass query as the object name(s) to look for, separated by ' . ' for "
|
|
143
|
+
"multiple (e.g. 'dog . frisbee'). Returns each detected instance with a "
|
|
144
|
+
"confidence score and its box as [x0, y0, x1, y1] fractions of the image, "
|
|
145
|
+
"plus an annotated copy of the image with the boxes drawn. Use this for "
|
|
146
|
+
"counting, presence checks, and locating objects."
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
DETECT_PARAMETERS = {
|
|
150
|
+
"type": "object",
|
|
151
|
+
"properties": {
|
|
152
|
+
"query": {
|
|
153
|
+
"type": "string",
|
|
154
|
+
"description": "Object name(s) to detect, ' . '-separated for multiple.",
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
"required": ["query"],
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
DEFAULT_DETECTOR_ID = "IDEA-Research/grounding-dino-tiny"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def default_detect_engine(
|
|
164
|
+
model_id: str = DEFAULT_DETECTOR_ID,
|
|
165
|
+
device: str = "cpu",
|
|
166
|
+
threshold: float = 0.3,
|
|
167
|
+
text_threshold: float = 0.25,
|
|
168
|
+
) -> Callable:
|
|
169
|
+
"""Build the default detector on Grounding DINO via transformers.
|
|
170
|
+
|
|
171
|
+
Returns ``engine(pil_image, query) -> list[{"label", "score", "box_px"}]``
|
|
172
|
+
(deterministic: pure forward pass, fixed thresholds, versioned weights).
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
import torch
|
|
176
|
+
from transformers import AutoModelForZeroShotObjectDetection, AutoProcessor
|
|
177
|
+
except ImportError as exc:
|
|
178
|
+
raise ImportError(
|
|
179
|
+
"image_detect's default engine needs torch+transformers "
|
|
180
|
+
"(pip install 'evalrx[local]'), or inject an engine via "
|
|
181
|
+
"detect_tool(..., engine=...)."
|
|
182
|
+
) from exc
|
|
183
|
+
|
|
184
|
+
processor = AutoProcessor.from_pretrained(model_id)
|
|
185
|
+
model = AutoModelForZeroShotObjectDetection.from_pretrained(model_id).to(device).eval()
|
|
186
|
+
|
|
187
|
+
def _engine(pil_image, query: str):
|
|
188
|
+
text = query.strip().rstrip(".") + "." # grounding-dino wants lowercase, dot-terminated
|
|
189
|
+
inputs = processor(images=pil_image, text=text.lower(), return_tensors="pt").to(device)
|
|
190
|
+
with torch.no_grad():
|
|
191
|
+
outputs = model(**inputs)
|
|
192
|
+
results = processor.post_process_grounded_object_detection(
|
|
193
|
+
outputs,
|
|
194
|
+
inputs.input_ids,
|
|
195
|
+
threshold=threshold,
|
|
196
|
+
text_threshold=text_threshold,
|
|
197
|
+
target_sizes=[pil_image.size[::-1]],
|
|
198
|
+
)[0]
|
|
199
|
+
out = []
|
|
200
|
+
for label, score, box in zip(
|
|
201
|
+
results["text_labels" if "text_labels" in results else "labels"],
|
|
202
|
+
results["scores"],
|
|
203
|
+
results["boxes"],
|
|
204
|
+
):
|
|
205
|
+
out.append(
|
|
206
|
+
{
|
|
207
|
+
"label": str(label),
|
|
208
|
+
"score": float(score),
|
|
209
|
+
"box_px": [float(v) for v in box.tolist()],
|
|
210
|
+
}
|
|
211
|
+
)
|
|
212
|
+
return out
|
|
213
|
+
|
|
214
|
+
return _engine
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _annotate(image, detections: list) -> Any:
|
|
218
|
+
"""Draw detection boxes + labels on a copy of *image*."""
|
|
219
|
+
from PIL import ImageDraw
|
|
220
|
+
|
|
221
|
+
annotated = image.copy()
|
|
222
|
+
draw = ImageDraw.Draw(annotated)
|
|
223
|
+
for i, det in enumerate(detections):
|
|
224
|
+
x0, y0, x1, y1 = det["box_px"]
|
|
225
|
+
draw.rectangle([x0, y0, x1, y1], outline=(208, 59, 59), width=3)
|
|
226
|
+
draw.text((x0 + 3, max(0, y0 - 12)), f"{i}: {det['label']} {det['score']:.2f}", fill=(208, 59, 59))
|
|
227
|
+
return annotated
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def detect_tool(
|
|
231
|
+
image: Any,
|
|
232
|
+
*,
|
|
233
|
+
engine: Optional[Callable] = None,
|
|
234
|
+
annotate: bool = True,
|
|
235
|
+
max_detections: int = 20,
|
|
236
|
+
) -> Tool:
|
|
237
|
+
"""Build the ``image_detect`` tool bound to *image*.
|
|
238
|
+
|
|
239
|
+
*engine* is ``callable(pil_image, query) -> list[{"label", "score",
|
|
240
|
+
"box_px"}]``; defaults to :func:`default_detect_engine` built lazily on
|
|
241
|
+
first call (weights load once per tool instance).
|
|
242
|
+
"""
|
|
243
|
+
base = _to_pil(image)
|
|
244
|
+
width, height = base.size
|
|
245
|
+
state = {"engine": engine}
|
|
246
|
+
|
|
247
|
+
def _get_engine() -> Callable:
|
|
248
|
+
if state["engine"] is None:
|
|
249
|
+
state["engine"] = default_detect_engine()
|
|
250
|
+
return state["engine"]
|
|
251
|
+
|
|
252
|
+
def _detect(query: str) -> ToolResult:
|
|
253
|
+
if not str(query).strip():
|
|
254
|
+
raise ValueError("query must name at least one object to detect")
|
|
255
|
+
detections = _get_engine()(base, str(query))
|
|
256
|
+
detections = sorted(detections, key=lambda d: -d["score"])[:max_detections]
|
|
257
|
+
meta: dict = {
|
|
258
|
+
"engine": "injected" if engine else DEFAULT_DETECTOR_ID,
|
|
259
|
+
"query": str(query),
|
|
260
|
+
"n_detections": len(detections),
|
|
261
|
+
"detections": [
|
|
262
|
+
{
|
|
263
|
+
"label": d["label"],
|
|
264
|
+
"score": round(d["score"], 3),
|
|
265
|
+
"box_frac": [
|
|
266
|
+
round(d["box_px"][0] / width, 4),
|
|
267
|
+
round(d["box_px"][1] / height, 4),
|
|
268
|
+
round(d["box_px"][2] / width, 4),
|
|
269
|
+
round(d["box_px"][3] / height, 4),
|
|
270
|
+
],
|
|
271
|
+
}
|
|
272
|
+
for d in detections
|
|
273
|
+
],
|
|
274
|
+
}
|
|
275
|
+
if not detections:
|
|
276
|
+
return ToolResult(
|
|
277
|
+
text=f"No instances of {query!r} detected (score threshold not reached).",
|
|
278
|
+
meta=meta,
|
|
279
|
+
)
|
|
280
|
+
lines = [
|
|
281
|
+
f"- #{i} {d['label']} (score {d['score']:.2f}) at "
|
|
282
|
+
f"[{d['box_frac'][0]:.2f}, {d['box_frac'][1]:.2f}, {d['box_frac'][2]:.2f}, {d['box_frac'][3]:.2f}]"
|
|
283
|
+
for i, d in enumerate(meta["detections"])
|
|
284
|
+
]
|
|
285
|
+
images = []
|
|
286
|
+
text_out = (
|
|
287
|
+
f"Detected {len(detections)} instance(s) of {query!r}, boxes as "
|
|
288
|
+
f"[x0, y0, x1, y1] fractions of the image:\n" + "\n".join(lines)
|
|
289
|
+
)
|
|
290
|
+
if annotate:
|
|
291
|
+
images.append(_annotate(base, detections))
|
|
292
|
+
text_out += "\nAn annotated copy of the image with numbered boxes is attached."
|
|
293
|
+
return ToolResult(text=text_out, images=images, meta=meta)
|
|
294
|
+
|
|
295
|
+
return Tool(
|
|
296
|
+
name="image_detect",
|
|
297
|
+
description=DETECT_DESCRIPTION,
|
|
298
|
+
parameters=DETECT_PARAMETERS,
|
|
299
|
+
fn=_detect,
|
|
300
|
+
)
|