# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Perception and vision tool implementations for the Explorer agent. Split out of ``artemis.agents.explorer.explorer``: the ``exec_*`` tool method group plus its search helpers, packaged as a mixin consumed by ``Explorer``. Patched collaborators (``settings``, ``draw_dots``, ``search_ui_func``, ``search_by_coordinates_func``, ``_run_object_detection``, ``StorageManager``, ``logger``) are resolved through the facade module at call time; see ``artemis.agents.explorer._facade``. """ import asyncio import glob import json from pathlib import Path import re import cv2 from artemis.agents.explorer._facade import facade from artemis.agents.image_processor.image_processor import ImageProcessor from artemis.data_engine.trace import trace def load_detector_templates() -> tuple[list, object]: """Load object-detector prompt templates and the optional global timeout. Shared by ``exec_detect_objects`` and the flash-mode ``run`` path; the two call sites historically carried an identical inline copy of this block. """ _ex = facade() detector_prompt_path = ( Path(__file__).parent.parent / "object_detector" / "object_detector.json" ) global_timeout = None try: with open(detector_prompt_path, encoding="utf-8") as f: detector_config = json.load(f) detector_templates = detector_config.get("templates", []) detector_instructions = detector_config.get("instructions", "") global_timeout = detector_config.get("global_timeout", None) except Exception as e: _ex.logger.warning(f"Failed to load detector prompt config: {e}") detector_templates = [ "Point to the following objects in the provided image: {labels_str}." ] detector_instructions = "" templates = [f"{t}\n\n{detector_instructions}" for t in detector_templates] return templates, global_timeout class PerceptionToolsMixin: """Perception/vision tool method group of :class:`Explorer`.""" async def _search_ui_helper(self, query: str, prefix: str = "S", color: str = "red") -> dict: _ex = facade() if not self.image_name: return { "text": ( "Error: UI data for the current screen is not found in Data" " Engine. It might not be synced yet." ), "image_path": None, } # 1. Try with high precision threshold raw_res = _ex.search_ui_func(self.image_name, query, 0.7) if "error" in raw_res: return {"text": raw_res["error"], "image_path": None} matches = raw_res.get("matches", []) # 2. If fewer than 3 matches, try with a lower threshold and merge if len(matches) < 3: raw_res_low = _ex.search_ui_func(self.image_name, query, 0.4) if "error" not in raw_res_low: low_matches = raw_res_low.get("matches", []) existing_keys = { ( m.get("matched_text"), tuple(m.get("bounds")) if m.get("bounds") else None, ) for m in matches } for m in low_matches: key = ( m.get("matched_text"), tuple(m.get("bounds")) if m.get("bounds") else None, ) if key not in existing_keys: matches.append(m) existing_keys.add(key) if not matches: return {"text": "No matches found.", "image_path": None} points = [] labels = [] text_output = [] for m in matches: m_type = m.get("type", "xml") m_prefix = "O" if m_type == "ocr" else prefix label_id = f"{m_prefix}{self.global_label_idx}" self.global_label_idx += 1 bounds = m.get("bounds") matched_text = m.get("matched_text", "") if bounds and len(bounds) == 4: left, top, right, bottom = bounds cx_pixel = (left + right) // 2 cy_pixel = (top + bottom) // 2 points.append([cx_pixel, cy_pixel]) labels.append(label_id) cx_norm = int(max(0, min(1000, cx_pixel * 1000 / self.width))) cy_norm = int(max(0, min(1000, cy_pixel * 1000 / self.height))) text_output.append(f"[{label_id}] '{matched_text}' at [{cx_norm},{cy_norm}]") else: text_output.append(f"[{label_id}] '{matched_text}' at unknown") if not points: return {"text": " | ".join(text_output), "image_path": None} base_dir = _ex.settings.TRACES_PATH images_dir = base_dir / "images" search_ui_dir = images_dir / "search_ui" search_ui_dir.mkdir(parents=True, exist_ok=True) existing_files = glob.glob(str(search_ui_dir / f"{self.image_name}_*.jpg")) max_seq = 0 for f in existing_files: match = re.search(r"_(\d+)\.jpg$", f) if match: max_seq = max(max_seq, int(match.group(1))) seq = max_seq + 1 output_path = search_ui_dir / f"{self.image_name}_{seq}.jpg" try: _ex.draw_dots( self.screenshot_path, points, labels, str(output_path), color=color, ) image_path = str(output_path) except Exception as e: _ex.logger.warning(f"Failed to draw dots: {e}") image_path = None return {"text": " | ".join(text_output), "image_path": image_path} async def _search_by_coords_helper( self, nx: int, ny: int, prefix: str = "X", color: str = "blue" ) -> dict: _ex = facade() if not self.image_name: return { "text": ( "Error: UI data for the current screen is not found in Data" " Engine. It might not be synced yet." ), "image_path": None, } x = int(max(0, min(self.width, nx * self.width / 1000))) y = int(max(0, min(self.height, ny * self.height / 1000))) raw_res = _ex.search_by_coordinates_func(self.image_name, x, y) label_id = f"{prefix}{self.global_label_idx}" self.global_label_idx += 1 # Draw a dot at the query coordinates base_dir = _ex.settings.TRACES_PATH images_dir = base_dir / "images" coords_audit_dir = images_dir / "coords_audit" coords_audit_dir.mkdir(parents=True, exist_ok=True) existing_files = glob.glob(str(coords_audit_dir / f"{self.image_name}_*.jpg")) max_seq = 0 for f in existing_files: match = re.search(r"_(\d+)\.jpg$", f) if match: max_seq = max(max_seq, int(match.group(1))) seq = max_seq + 1 output_path = coords_audit_dir / f"{self.image_name}_{seq}.jpg" try: _ex.draw_dots( self.screenshot_path, [[x, y]], [label_id], str(output_path), color=color, ) image_path = str(output_path) except Exception as e: _ex.logger.warning(f"Failed to draw blue dots: {e}") image_path = None raw_res_str = str(raw_res).replace("\n", " | ").replace(" | | ", " | ") return { "text": (f"Matched element at [{nx},{ny}]: [{label_id}] | {raw_res_str}"), "image_path": image_path, } @trace(type="tool", name="detect_objects") async def exec_detect_objects( self, queries: list[str], target_image_id: str = "img_0", prefix: str = "D", color: str = "red", ) -> dict: _ex = facade() try: target_info = self.image_pool.get(target_image_id) if not target_info: return { "text": (f"Error: {target_image_id} not found in Image Pool."), "image_path": None, } target_path = target_info["path"] transform = target_info["transform"] # Load object detector templates templates, global_timeout = load_detector_templates() result = await _ex._run_object_detection( self.ctx, target_path, queries, templates, global_timeout=global_timeout, ) try: detected_items = result.get("detected", []) failed_queries = result.get("failed", []) if not detected_items: text_output = ["No objects detected."] if failed_queries: text_output.append( "\n[NOTE: Failed to detect the following queries:" f" {', '.join(failed_queries)}]" ) return { "text": "".join(text_output), "image_path": str(target_path), } base_dir = _ex.settings.TRACES_PATH images_dir = base_dir / "images" object_detect_dir = images_dir / "object_detect" object_detect_dir.mkdir(parents=True, exist_ok=True) image_name_safe = self.image_name or "temp_image" existing_files = glob.glob(str(object_detect_dir / f"{image_name_safe}_*.jpg")) max_seq = 0 for f in existing_files: match = re.search(r"_(\d+)\.jpg$", f) if match: max_seq = max(max_seq, int(match.group(1))) seq = max_seq + 1 output_path = object_detect_dir / f"{image_name_safe}_{seq}.jpg" points = [] d_labels = [] text_output = [] if target_image_id != "img_0": text_output.append( "[NOTE: Coordinates are automatically mapped back to" " the original 1080x2400 screen space from" f" {target_image_id}]" ) for item in detected_items: label_id = f"{prefix}{self.global_label_idx}" self.global_label_idx += 1 pos = item.get("point") if pos and isinstance(pos, list) and len(pos) == 2: x_norm, y_norm = pos t_img = cv2.imread(target_path) t_h, t_w = t_img.shape[:2] # Pixels in target image x_target_pixel = (x_norm / 1000) * t_w y_target_pixel = (y_norm / 1000) * t_h # Pixels in original image x_orig_pixel = (x_target_pixel / transform["scale_x"]) + transform[ "offset_x" ] y_orig_pixel = (y_target_pixel / transform["scale_y"]) + transform[ "offset_y" ] points.append([int(x_orig_pixel), int(y_orig_pixel)]) d_labels.append(label_id) # Original image norm x_orig_norm = int(max(0, min(1000, x_orig_pixel * 1000 / self.width))) y_orig_norm = int(max(0, min(1000, y_orig_pixel * 1000 / self.height))) text_output.append( f'[{label_id}] "{item.get("label")}" at [{x_orig_norm},{y_orig_norm}]' ) _ex.draw_dots( self.screenshot_path, points, d_labels, str(output_path), color=color, ) if failed_queries: text_output.append( " | [NOTE: Failed to detect the following queries:" f" {', '.join(failed_queries)}]" ) return { "text": " | ".join(text_output), "image_path": str(output_path), } except Exception as e: _ex.logger.warning(f"Failed to process detection output for annotation: {e}") return {"text": str(result), "image_path": None} except Exception as e: return { "text": f"Error: Object detection failed: {e}", "image_path": None, } @trace(type="tool", name="ask_perception_tool") async def exec_ask_perception_tool( self, search_query: str, nx: int, ny: int, detect_queries: list[str] ) -> dict: if nx is None or ny is None or not (0 <= nx <= 1000) or not (0 <= ny <= 1000): coords_invalid = True else: coords_invalid = False # Run tasks concurrently in parallel! tasks = [ self._search_ui_helper(search_query, prefix="X", color="green"), self._search_by_coords_helper(nx, ny, prefix="X", color="blue") if not coords_invalid else asyncio.sleep( 0, result={ "text": "Error: Invalid coordinates format", "image_path": None, }, ), self.exec_detect_objects( detect_queries, target_image_id="img_0", prefix="D", color="red" ), ] results = await asyncio.gather(*tasks) # Consolidate text results text_parts = [] xml_text = results[0].get("text", "No matches found.") if xml_text and xml_text != "No matches found.": text_parts.append(f"Text Search Results are: {xml_text}") coord_text = results[1].get("text", "No elements found.") if coord_text and coord_text != "No elements found.": text_parts.append(f"Coordinate Search Results are: {coord_text}") obj_text = results[2].get("text", "No objects detected.") if obj_text: if obj_text.startswith("No objects detected."): note_part = obj_text.replace("No objects detected.", "").strip() if note_part: text_parts.append(f"Object Detection Results are: {note_part}") else: text_parts.append(f"Object Detection Results are: {obj_text}") if not text_parts: unified_text = "No matches or objects found across any perception method." else: unified_text = ". ".join(text_parts) + "." # Collect image paths image_paths = [] for r in results: if isinstance(r, dict) and r.get("image_path"): image_paths.append(r["image_path"]) return { "text": unified_text, "image_paths": image_paths, } @trace(type="tool", name="ask_image_processor") async def exec_ask_image_processor( self, instruction: str, target_image_id: str = "img_0" ) -> dict: coder = ImageProcessor(self.ctx) target_info = self.image_pool.get(target_image_id) if not target_info: return { "text": f"Error: {target_image_id} not found in Image Pool.", "image_path": None, } target_path = target_info["path"] result = await coder.run(instruction, target_path) if not isinstance(result, dict) or "error" in result: err_msg = result.get("error") if isinstance(result, dict) else "Unknown error" return { "text": f"Image Processor failed: {err_msg}", "image_paths": [], } outputs = result.get("outputs", []) summary = result.get("summary", "") parent_transform = target_info["transform"] registered_ids = [] annotations_sections = [] image_paths = [] for entry in outputs: new_id = f"img_{self.next_img_id}" self.next_img_id += 1 new_path = entry["path"] image_paths.append(new_path) transform = entry["transform"] final_scale_x = parent_transform["scale_x"] * transform["scale_x"] final_scale_y = parent_transform["scale_y"] * transform["scale_y"] final_offset_x = ( transform["offset_x"] / parent_transform["scale_x"] ) + parent_transform["offset_x"] final_offset_y = ( transform["offset_y"] / parent_transform["scale_y"] ) + parent_transform["offset_y"] self.image_pool[new_id] = { "path": new_path, "transform": { "offset_x": final_offset_x, "offset_y": final_offset_y, "scale_x": final_scale_x, "scale_y": final_scale_y, }, "description": (f"Generated by coder from {target_image_id}: {summary}"), } registered_ids.append(new_id) # Parse and translate annotations annotations = entry.get("annotations", {}) if annotations: lines = [f"{new_id}:"] for label, coord in sorted(annotations.items(), key=lambda item: item[0]): px, py = coord sx = int(round((px / final_scale_x) + final_offset_x)) sy = int(round((py / final_scale_y) + final_offset_y)) lines.append(f" - [{label}]: [{sx}, {sy}]") annotations_sections.append("\n".join(lines)) if registered_ids: ids_str = ", ".join(registered_ids) text = ( "Image Processor completed successfully. New image(s) saved as" f" {ids_str} in Image Pool. Summary: {summary}" ) if annotations_sections: text += "\n\nAnnotations (wrt original screenshot):\n" + "\n".join( annotations_sections ) return { "text": text, "image_paths": image_paths, } else: return { "text": f"ImageProcessor error: {err_msg}", "image_paths": [], } output_path = result.get("output_path") trans = result.get("transform") new_image_id = f"img_{len(self.image_pool)}" if output_path and trans: self.image_pool[new_image_id] = { "path": output_path, "transform": trans, } text_msg = ( f"Image processed successfully. New image saved as" f" '{new_image_id}'. You can now use '{new_image_id}' as" " 'target_image_id' in subsequent tool calls." ) return { "text": text_msg, "image_path": output_path, "image_id": new_image_id, } @trace(type="tool", name="get_ocr_list") async def exec_get_ocr_list(self, prefix: str = "O", color: str = "green") -> dict: _ex = facade() if not self.image_name: return { "text": ("Error: UI data for the current screen is not found in Data Engine."), "image_path": None, } try: db_path = _ex.settings.DATA_ENGINE_DB_PATH base_dir = _ex.settings.TRACES_PATH storage = _ex.StorageManager(db_path=str(db_path), base_dir=base_dir) record = storage.get_ui_record(self.image_name) if not record or not record.ocr_result: return { "text": "No text elements detected on the screen.", "image_path": None, } points = [] labels = [] text_output = [] for item in record.ocr_result: text = item.get("text", "").strip() bounds = item.get("bounds", []) if text and len(bounds) == 4: left, top, right, bottom = bounds cx = (left + right) // 2 cy = (top + bottom) // 2 if 0 <= cx <= self.width and 0 <= cy <= self.height: label_id = f"{prefix}{self.global_label_idx}" self.global_label_idx += 1 points.append([cx, cy]) labels.append(label_id) cx_norm = int(max(0, min(1000, cx * 1000 / self.width))) cy_norm = int(max(0, min(1000, cy * 1000 / self.height))) text_output.append(f"[{label_id}] '{text}' coords: [{cx_norm},{cy_norm}]") if not points: return { "text": "No valid OCR results found.", "image_path": None, } images_dir = base_dir / "images" ocr_dir = images_dir / "ocr" ocr_dir.mkdir(parents=True, exist_ok=True) output_path = ocr_dir / f"{self.image_name}_{self.global_label_idx}.jpg" _ex.draw_dots(self.screenshot_path, points, labels, str(output_path), color=color) return { "text": "\n".join(text_output), "image_path": str(output_path), } except Exception as e: return { "text": f"Error retrieving or processing OCR results: {e}", "image_path": None, } @trace(type="tool", name="inspect_region") async def exec_inspect_region( self, x_min: int, y_min: int, x_max: int, y_max: int, zoom_factor: float = 2.0, ) -> dict: _ex = facade() try: px_start = int(max(0, min(self.width, x_min * self.width / 1000.0))) py_start = int(max(0, min(self.height, y_min * self.height / 1000.0))) px_end = int(max(0, min(self.width, x_max * self.width / 1000.0))) py_end = int(max(0, min(self.height, y_max * self.height / 1000.0))) if px_end <= px_start or py_end <= py_start: return { "text": ( f"Error: Invalid crop coordinates. x_min={x_min}," f" x_max={x_max}, y_min={y_min}, y_max={y_max}." " Bounding box dimensions must be strictly positive." ), "image_path": None, } img = cv2.imread(self.screenshot_path) if img is None: return { "text": ( f"Error: Failed to read original screenshot at {self.screenshot_path}" ), "image_path": None, } cropped = img[py_start:py_end, px_start:px_end] if zoom_factor is None: zoom_factor = 2.0 zoom_factor = max(1.0, min(4.0, float(zoom_factor))) c_h, c_w = cropped.shape[:2] if c_h > 0 and c_w > 0: new_w = int(round(c_w * zoom_factor)) new_h = int(round(c_h * zoom_factor)) if new_w > 0 and new_h > 0: cropped = cv2.resize(cropped, (new_w, new_h)) base_dir = _ex.settings.TRACES_PATH images_dir = base_dir / "images" inspect_region_dir = images_dir / "inspect_region" inspect_region_dir.mkdir(parents=True, exist_ok=True) image_name_safe = self.image_name or "temp_image" output_path = inspect_region_dir / f"{image_name_safe}_{self.global_label_idx}.jpg" cv2.imwrite(str(output_path), cropped) return { "text": (f"Inspected region coordinates: [{x_min}, {y_min}] to [{x_max}, {y_max}]"), "image_path": str(output_path), } except Exception as e: return { "text": f"Error: Region inspection failed: {e}", "image_path": None, }