Skip to content

Commit a5ca5dd

Browse files
committed
update pointing client side
1 parent 86d4ee8 commit a5ca5dd

3 files changed

Lines changed: 342 additions & 3 deletions

File tree

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
"""High-level helpers for Gemini-based object pointing/detection."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import logging
7+
from dataclasses import dataclass
8+
from pathlib import Path
9+
from typing import List, Optional, Sequence, Tuple, TypeVar, Union
10+
11+
import numpy as np
12+
from PIL import Image
13+
14+
from predicators.spot_utils.perception.pointing_gemini_sam2_client import \
15+
PointingGeminiSAM2Client
16+
from predicators.spot_utils.perception.utils.gemini_parsing import \
17+
denormalize_box, denormalize_point
18+
19+
20+
ImageLike = Union[str, Image.Image, np.ndarray]
21+
22+
23+
@dataclass
24+
class GeminiPointPrediction:
25+
"""Pixel selected by Gemini's pointing response."""
26+
label: str
27+
pixel: Tuple[int, int]
28+
29+
30+
@dataclass
31+
class GeminiBoxPrediction:
32+
"""Bounding-box detection returned by Gemini."""
33+
label: str
34+
box: Tuple[float, float, float, float]
35+
36+
@property
37+
def centroid(self) -> Tuple[int, int]:
38+
x1, y1, x2, y2 = self.box
39+
return (int(round((x1 + x2) / 2.0)), int(round((y1 + y2) / 2.0)))
40+
41+
42+
@dataclass
43+
class GeminiPointingResult:
44+
"""Aggregated pointing/detection results for an image/prompt pair."""
45+
image_index: int
46+
prompt: str
47+
points: List[GeminiPointPrediction]
48+
boxes: List[GeminiBoxPrediction]
49+
image_path: Optional[str] = None
50+
51+
def primary_pixel(self) -> Optional[Tuple[int, int]]:
52+
"""Return the best-guess pixel (points first, detection centroid second)."""
53+
if self.points:
54+
return self.points[0].pixel
55+
if self.boxes:
56+
return self.boxes[0].centroid
57+
return None
58+
59+
60+
def _to_pil(image: ImageLike) -> Image.Image:
61+
if isinstance(image, Image.Image):
62+
return image
63+
if isinstance(image, np.ndarray):
64+
if image.dtype != np.uint8:
65+
raise ValueError("numpy image must have dtype uint8")
66+
if image.ndim == 2:
67+
return Image.fromarray(image)
68+
if image.ndim == 3:
69+
return Image.fromarray(image)
70+
raise ValueError(f"Unsupported ndarray shape {image.shape}")
71+
if isinstance(image, str):
72+
return Image.open(image)
73+
raise TypeError(f"Unsupported image type: {type(image)}")
74+
75+
76+
T = TypeVar("T")
77+
78+
79+
def _coerce_sequence(value: Union[T, Sequence[T]]) -> List[T]:
80+
if isinstance(value, (list, tuple)):
81+
return list(value)
82+
return [value]
83+
84+
85+
def _denormalize_points(points: List[dict], width: int,
86+
height: int) -> List[GeminiPointPrediction]:
87+
results: List[GeminiPointPrediction] = []
88+
for entry in points:
89+
y_norm, x_norm = entry["point"]
90+
y, x = denormalize_point([y_norm, x_norm], (width, height))
91+
pixel = (int(round(x)), int(round(y)))
92+
results.append(GeminiPointPrediction(entry.get("label", ""), pixel))
93+
return results
94+
95+
96+
def _denormalize_boxes(boxes: List[List[float]], width: int, height: int,
97+
label: str) -> List[GeminiBoxPrediction]:
98+
results: List[GeminiBoxPrediction] = []
99+
for box in boxes:
100+
x1, y1, x2, y2 = denormalize_box(box, (width, height))
101+
results.append(GeminiBoxPrediction(label, (x1, y1, x2, y2)))
102+
return results
103+
104+
105+
def point_objects_with_gemini(
106+
images: Union[ImageLike, Sequence[ImageLike]],
107+
prompts: Union[str, Sequence[str]],
108+
*,
109+
host: str = "localhost",
110+
port: int = 7100,
111+
detection: bool = False,
112+
segmentation: bool = False) -> List[GeminiPointingResult]:
113+
"""Call the Gemini pointing service and parse the response."""
114+
image_list = [_to_pil(img) for img in _coerce_sequence(images)]
115+
prompt_list = [str(p) for p in _coerce_sequence(prompts)]
116+
client = PointingGeminiSAM2Client(host=host, port=port)
117+
response = client.predict(image_list,
118+
prompt_list,
119+
points=not detection,
120+
segmentation=segmentation,
121+
detection=detection)
122+
123+
raw_results = response.get("results", [])
124+
parsed: List[GeminiPointingResult] = []
125+
for entry in raw_results:
126+
width = entry.get("image_width")
127+
height = entry.get("image_height")
128+
if width is None or height is None:
129+
logging.warning("[GeminiPointing] Missing image size in response; "
130+
"skipping entry.")
131+
continue
132+
points_data = entry.get("points") or []
133+
boxes_data = entry.get("boxes") or []
134+
label = entry.get("prompt", "")
135+
points = _denormalize_points(points_data, width, height)
136+
boxes = _denormalize_boxes(boxes_data, width, height, label)
137+
parsed.append(
138+
GeminiPointingResult(
139+
image_index=entry.get("image_index", 0),
140+
prompt=entry.get("prompt", ""),
141+
points=points,
142+
boxes=boxes,
143+
image_path=None,
144+
))
145+
return parsed
146+
147+
148+
def point_single_image(
149+
image: ImageLike,
150+
prompt: str,
151+
*,
152+
host: str = "localhost",
153+
port: int = 7100,
154+
detection: bool = False) -> Optional[GeminiPointingResult]:
155+
"""Convenience wrapper for a single image/prompt pair."""
156+
results = point_objects_with_gemini(image, prompt, host=host, port=port,
157+
detection=detection)
158+
return results[0] if results else None
159+
160+
161+
def _cli() -> None:
162+
parser = argparse.ArgumentParser(description="Gemini object pointing CLI")
163+
parser.add_argument("image",
164+
type=Path,
165+
help="Path to the image to analyze.")
166+
parser.add_argument("prompt", type=str, help="Prompt describing the object.")
167+
parser.add_argument("--host",
168+
default="localhost",
169+
help="Gemini pointing server host.")
170+
parser.add_argument("--port",
171+
default=7100,
172+
type=int,
173+
help="Gemini pointing server port.")
174+
parser.add_argument("--detection",
175+
action="store_true",
176+
help="Request detection boxes instead of direct points.")
177+
args = parser.parse_args()
178+
179+
result = point_single_image(args.image,
180+
args.prompt,
181+
host=args.host,
182+
port=args.port,
183+
detection=args.detection)
184+
if result is None:
185+
print("No pointing result returned.")
186+
return
187+
print(f"Prompt: {result.prompt}")
188+
if result.points:
189+
print("Points:")
190+
for point in result.points:
191+
print(f" {point.label}: {point.pixel}")
192+
if result.boxes:
193+
print("Detections:")
194+
for box in result.boxes:
195+
print(f" {box.label}: {box.box} (centroid={box.centroid})")
196+
197+
198+
if __name__ == "__main__": # pragma: no cover - CLI utility
199+
_cli()

predicators/spot_utils/perception/pointing_gemini_sam2_client.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,31 @@ async def predict_async(
145145
},
146146
timeout=120.0
147147
)
148-
148+
149149
progress.update(task, completed=True)
150+
151+
if response.status_code != 200:
152+
raise RuntimeError(
153+
f"Pointing service returned status {response.status_code}:\n"
154+
f"{response.text}"
155+
)
156+
150157
result = response.json()
151-
console.print("[green]Server request completed successfully[/green]")
158+
entries = []
159+
count = "unknown"
160+
example = None
161+
if isinstance(result, dict):
162+
entries = result.get("results") or []
163+
count = len(entries)
164+
example = entries[0].get("prompt") if entries else None
165+
if not entries:
166+
logging.warning(
167+
"[PointingClient] No detections returned (HTTP 200)"
168+
"; check server logs for details.")
169+
extra = f", sample='{example}'" if example else ""
170+
console.print(
171+
f"[green]Server request completed successfully[/green]"
172+
f" (results={count}{extra})")
152173
timings["server_request"] = t.elapsed_time
153174

154175
return {"results": result, "timings": timings}
@@ -235,4 +256,4 @@ def predict(
235256

236257

237258
if __name__ == "__main__":
238-
app()
259+
app()
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""Adapter that proxies hand-view pointing requests to Gemini."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
import time
7+
from dataclasses import dataclass
8+
from pathlib import Path
9+
from typing import Any, Dict, Optional, Tuple
10+
11+
from bosdyn.client import math_helpers
12+
from PIL import Image, ImageDraw
13+
14+
from predicators.settings import CFG
15+
from predicators.spot_utils.perception.object_pointing import \
16+
GeminiPointingResult, point_single_image
17+
from predicators.spot_utils.perception.perception_structs import \
18+
ObjectDetectionID, RGBDImageWithContext
19+
from predicators.structs import Object
20+
21+
_POINTING_SERVICE_AVAILABLE = True
22+
_POINTING_WARNING_EMITTED = False
23+
24+
25+
@dataclass
26+
class VLMPointingResult:
27+
"""Record of a pixel selected for a target object."""
28+
object_name: str
29+
pixel: Tuple[int, int]
30+
orientation: Optional[math_helpers.Quat]
31+
source: str = "gemini"
32+
33+
34+
def compute_pointing_result( # pylint: disable=unused-argument
35+
target_object: Object,
36+
images: Dict[str, RGBDImageWithContext],
37+
detection_artifacts: Optional[Dict[str, Any]],
38+
detection_id_to_obj: Dict[ObjectDetectionID, Object],
39+
rng: Optional[Any],
40+
camera_name: str = "hand_color_image") -> Optional[VLMPointingResult]:
41+
"""Select a pixel for the target object via Gemini pointing service."""
42+
del detection_artifacts, detection_id_to_obj, rng # unused in Gemini path
43+
global _POINTING_SERVICE_AVAILABLE, _POINTING_WARNING_EMITTED # pylint:disable=global-statement
44+
45+
if not CFG.spot_use_vlm_pointing:
46+
return None
47+
if not _POINTING_SERVICE_AVAILABLE:
48+
if not _POINTING_WARNING_EMITTED:
49+
logging.warning("Gemini pointing disabled after connection failure; "
50+
"skipping until service becomes reachable again.")
51+
_POINTING_WARNING_EMITTED = True
52+
return None
53+
if camera_name not in images:
54+
logging.debug("[GeminiPointing] Camera %s missing; cannot compute pixel.",
55+
camera_name)
56+
return None
57+
rgbd = images[camera_name]
58+
try:
59+
pil_image = Image.fromarray(rgbd.rgb)
60+
except Exception as exc: # pragma: no cover - defensive
61+
logging.debug("Failed to convert image for Gemini pointing: %s", exc)
62+
return None
63+
64+
detection_mode = CFG.spot_pointing_mode.lower().startswith("det")
65+
prompt = f"Point to the {target_object.name}"
66+
try:
67+
result = point_single_image(pil_image,
68+
prompt,
69+
host=CFG.spot_pointing_host,
70+
port=CFG.spot_pointing_port,
71+
detection=detection_mode)
72+
except Exception as exc: # pragma: no cover - network failure
73+
logging.warning("Gemini pointing failed: %s", exc)
74+
_POINTING_SERVICE_AVAILABLE = False
75+
_POINTING_WARNING_EMITTED = False
76+
return None
77+
if result is None:
78+
return None
79+
_POINTING_SERVICE_AVAILABLE = True
80+
_POINTING_WARNING_EMITTED = False
81+
if CFG.spot_pointing_debug_visuals:
82+
_save_pointing_debug_visual(target_object.name, rgbd, result)
83+
pixel = result.primary_pixel()
84+
if pixel is None:
85+
return None
86+
return VLMPointingResult(target_object.name,
87+
pixel,
88+
orientation=None,
89+
source="gemini")
90+
91+
92+
def _save_pointing_debug_visual(target_name: str,
93+
rgbd: RGBDImageWithContext,
94+
result: GeminiPointingResult) -> None:
95+
"""Best-effort visualization of Gemini pointing results."""
96+
try:
97+
outdir = Path(CFG.spot_pointing_debug_dir)
98+
outdir.mkdir(parents=True, exist_ok=True)
99+
image = Image.fromarray(rgbd.rgb.copy())
100+
draw = ImageDraw.Draw(image)
101+
for point in result.points:
102+
x, y = point.pixel
103+
r = 8
104+
draw.ellipse((x - r, y - r, x + r, y + r),
105+
outline="red",
106+
width=2)
107+
draw.text((x + 5, y - 5), point.label or "", fill="red")
108+
for box in result.boxes:
109+
x1, y1, x2, y2 = box.box
110+
draw.rectangle((x1, y1, x2, y2), outline="cyan", width=2)
111+
draw.text((x1 + 5, y1 - 5), box.label or "", fill="cyan")
112+
timestamp = time.strftime("%Y%m%d-%H%M%S")
113+
filename = outdir / \
114+
f"{timestamp}_{target_name}_{rgbd.camera_name}_pointing.png"
115+
image.save(filename)
116+
logging.info("[GeminiPointing] Saved debug visualization to %s",
117+
filename)
118+
except Exception as exc: # pragma: no cover - best-effort logging
119+
logging.debug("Failed to save pointing debug visual: %s", exc)

0 commit comments

Comments
 (0)