Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add detector tool #13

Merged
merged 7 commits into from
Mar 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
940 changes: 364 additions & 576 deletions poetry.lock

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions vision_agent/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .lmm import LMM, LLaVALMM, OpenAILMM, get_lmm
from .emb import Embedder, SentenceTransformerEmb, OpenAIEmb, get_embedder
from .data import DataStore, build_data_store
from .emb import Embedder, OpenAIEmb, SentenceTransformerEmb, get_embedder
from .llm import LLM, OpenAILLM
from .lmm import LMM, LLaVALMM, OpenAILMM, get_lmm
2 changes: 1 addition & 1 deletion vision_agent/data/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import uuid
from pathlib import Path
from typing import Dict, List, Optional, Union, cast, Callable
from typing import Callable, Dict, List, Optional, Union, cast

import faiss
import numpy as np
Expand Down
17 changes: 14 additions & 3 deletions vision_agent/image_utils.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
import base64
from io import BytesIO
from pathlib import Path
from typing import Union
from typing import Tuple, Union

import numpy as np
from PIL import Image
from PIL.Image import Image as ImageType


def b64_to_pil(b64_str: str) -> Image.Image:
def b64_to_pil(b64_str: str) -> ImageType:
# , can't be encoded in b64 data so must be part of prefix
if "," in b64_str:
b64_str = b64_str.split(",")[1]
return Image.open(BytesIO(base64.b64decode(b64_str)))


def convert_to_b64(data: Union[str, Path, np.ndarray, Image.Image]) -> str:
def get_image_size(data: Union[str, Path, np.ndarray, ImageType]) -> Tuple[int, ...]:
if isinstance(data, (str, Path)):
data = Image.open(data)

if isinstance(data, Image.Image):
return data.size[::-1]
else:
return data.shape[:2]


def convert_to_b64(data: Union[str, Path, np.ndarray, ImageType]) -> str:
if data is None:
raise ValueError(f"Invalid input image: {data}. Input image can't be None.")
if isinstance(data, (str, Path)):
Expand Down
1 change: 1 addition & 0 deletions vision_agent/llm/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .llm import LLM, OpenAILLM
86 changes: 86 additions & 0 deletions vision_agent/llm/llm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import json
from abc import ABC, abstractmethod
from typing import Mapping, cast

from vision_agent.tools import (
CHOOSE_PARAMS,
CLIP,
SYSTEM_PROMPT,
GroundingDINO,
GroundingSAM,
ImageTool,
)


class LLM(ABC):
@abstractmethod
def generate(self, prompt: str) -> str:
pass


class OpenAILLM(LLM):
r"""An LLM class for any OpenAI LLM model."""

def __init__(self, model_name: str = "gpt-4-turbo-preview"):
from openai import OpenAI

self.model_name = model_name
self.client = OpenAI()

def generate(self, prompt: str) -> str:
response = self.client.chat.completions.create(
model=self.model_name,
messages=[
{"role": "user", "content": prompt},
],
)

return cast(str, response.choices[0].message.content)

def generate_classifier(self, prompt: str) -> ImageTool:
prompt = CHOOSE_PARAMS.format(api_doc=CLIP.doc, question=prompt)
response = self.client.chat.completions.create(
model=self.model_name,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
)

params = json.loads(cast(str, response.choices[0].message.content))[
"Parameters"
]
return CLIP(**cast(Mapping, params))

def generate_detector(self, params: str) -> ImageTool:
params = CHOOSE_PARAMS.format(api_doc=GroundingDINO.doc, question=params)
response = self.client.chat.completions.create(
model=self.model_name,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": params},
],
)

params = json.loads(cast(str, response.choices[0].message.content))[
"Parameters"
]
return GroundingDINO(**cast(Mapping, params))

def generate_segmentor(self, params: str) -> ImageTool:
params = CHOOSE_PARAMS.format(api_doc=GroundingSAM.doc, question=params)
response = self.client.chat.completions.create(
model=self.model_name,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": params},
],
)

params = json.loads(cast(str, response.choices[0].message.content))[
"Parameters"
]
return GroundingSAM(**cast(Mapping, params))
41 changes: 19 additions & 22 deletions vision_agent/lmm/lmm.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import logging
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any, Dict, List, Optional, Union, cast
from typing import Any, Dict, List, Mapping, Optional, Union, cast

import requests

Expand Down Expand Up @@ -38,8 +38,8 @@ def generate(self, prompt: str, image: Optional[Union[str, Path]] = None) -> str
class LLaVALMM(LMM):
r"""An LMM class for the LLaVA-1.6 34B model."""

def __init__(self, name: str):
self.name = name
def __init__(self, model_name: str):
self.model_name = model_name

def generate(
self,
Expand Down Expand Up @@ -67,10 +67,10 @@ def generate(
class OpenAILMM(LMM):
r"""An LMM class for the OpenAI GPT-4 Vision model."""

def __init__(self, name: str):
def __init__(self, model_name: str = "gpt-4-vision-preview"):
from openai import OpenAI

self.name = name
self.model_name = model_name
self.client = OpenAI()

def generate(self, prompt: str, image: Optional[Union[str, Path]] = None) -> str:
Expand All @@ -96,15 +96,14 @@ def generate(self, prompt: str, image: Optional[Union[str, Path]] = None) -> str
)

response = self.client.chat.completions.create(
model="gpt-4-vision-preview", messages=message # type: ignore
model=self.model_name, messages=message # type: ignore
)
return cast(str, response.choices[0].message.content)

def generate_classifier(self, prompt: str) -> ImageTool:
prompt = CHOOSE_PARAMS.format(api_doc=CLIP.doc, question=prompt)
response = self.client.chat.completions.create(
model="gpt-4-turbo-preview", # no need to use vision model here
response_format={"type": "json_object"},
model=self.model_name,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
Expand All @@ -113,44 +112,42 @@ def generate_classifier(self, prompt: str) -> ImageTool:

try:
prompt = json.loads(cast(str, response.choices[0].message.content))[
"prompt"
"Parameters"
]
except json.JSONDecodeError:
_LOGGER.error(
f"Failed to decode response: {response.choices[0].message.content}"
)
raise ValueError("Failed to decode response")

return CLIP(prompt)
return CLIP(**cast(Mapping, prompt))

def generate_detector(self, prompt: str) -> ImageTool:
prompt = CHOOSE_PARAMS.format(api_doc=GroundingDINO.doc, question=prompt)
def generate_detector(self, params: str) -> ImageTool:
params = CHOOSE_PARAMS.format(api_doc=GroundingDINO.doc, question=params)
response = self.client.chat.completions.create(
model="gpt-4-turbo-preview", # no need to use vision model here
response_format={"type": "json_object"},
model=self.model_name,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
{"role": "user", "content": params},
],
)

try:
prompt = json.loads(cast(str, response.choices[0].message.content))[
"prompt"
params = json.loads(cast(str, response.choices[0].message.content))[
"Parameters"
]
except json.JSONDecodeError:
_LOGGER.error(
f"Failed to decode response: {response.choices[0].message.content}"
)
raise ValueError("Failed to decode response")

return GroundingDINO(prompt)
return GroundingDINO(**cast(Mapping, params))

def generate_segmentor(self, prompt: str) -> ImageTool:
prompt = CHOOSE_PARAMS.format(api_doc=GroundingSAM.doc, question=prompt)
response = self.client.chat.completions.create(
model="gpt-4-turbo-preview", # no need to use vision model here
response_format={"type": "json_object"},
model=self.model_name,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
Expand All @@ -159,15 +156,15 @@ def generate_segmentor(self, prompt: str) -> ImageTool:

try:
prompt = json.loads(cast(str, response.choices[0].message.content))[
"prompt"
"Parameters"
]
except json.JSONDecodeError:
_LOGGER.error(
f"Failed to decode response: {response.choices[0].message.content}"
)
raise ValueError("Failed to decode response")

return GroundingSAM(prompt)
return GroundingSAM(**cast(Mapping, prompt))


def get_lmm(name: str) -> LMM:
Expand Down
4 changes: 2 additions & 2 deletions vision_agent/tools/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
from .prompts import SYSTEM_PROMPT, CHOOSE_PARAMS
from .tools import ImageTool, CLIP, GroundingDINO, GroundingSAM
from .prompts import CHOOSE_PARAMS, SYSTEM_PROMPT
from .tools import CLIP, GroundingDINO, GroundingSAM, ImageTool
35 changes: 31 additions & 4 deletions vision_agent/tools/tools.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
import logging
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any, Dict, List, Union, cast
from typing import Any, Dict, List, Tuple, Union, cast

import requests
from PIL.Image import Image as ImageType

from vision_agent.image_utils import convert_to_b64
from vision_agent.image_utils import convert_to_b64, get_image_size

_LOGGER = logging.getLogger(__name__)


def normalize_bbox(
bbox: List[Union[int, float]], image_size: Tuple[int, ...]
) -> List[float]:
r"""Normalize the bounding box coordinates to be between 0 and 1."""
x1, y1, x2, y2 = bbox
x1 = x1 / image_size[1]
y1 = y1 / image_size[0]
x2 = x2 / image_size[1]
y2 = y2 / image_size[0]
return [x1, y1, x2, y2]


class ImageTool(ABC):
@abstractmethod
def __call__(self, image: Union[str, ImageType]) -> List[Dict]:
Expand Down Expand Up @@ -42,12 +54,18 @@ class GroundingDINO(ImageTool):
'Example 1: User Question: "Can you build me a car detector?" {{"Parameters":{{"prompt": "car"}}}}\n'
'Example 2: User Question: "Can you detect the person on the left?" {{"Parameters":{{"prompt": "person on the left"}}\n'
'Exmaple 3: User Question: "Can you build me a tool that detects red shirts and green shirts?" {{"Parameters":{{"prompt": "red shirt. green shirt"}}}}\n'
"The tool returns a list of dictionaries, each containing the following keys:\n"
" - 'lable': The label of the detected object.\n"
" - 'score': The confidence score of the detection.\n"
" - 'bbox': The bounding box of the detected object. The box coordinates are normalize to [0, 1]\n"
"An example output would be: [{'label': ['car'], 'score': [0.99], 'bbox': [[0.1, 0.2, 0.3, 0.4]]}]\n"
)

def __init__(self, prompt: str):
self.prompt = prompt

def __call__(self, image: Union[str, Path, ImageType]) -> List[Dict]:
image_size = get_image_size(image)
image_b64 = convert_to_b64(image)
data = {
"prompt": self.prompt,
Expand All @@ -59,9 +77,18 @@ def __call__(self, image: Union[str, Path, ImageType]) -> List[Dict]:
json=data,
)
resp_json: Dict[str, Any] = res.json()
if resp_json["statusCode"] != 200:
if (
"statusCode" in resp_json and resp_json["statusCode"] != 200
) or "statusCode" not in resp_json:
_LOGGER.error(f"Request failed: {resp_json}")
return cast(List[Dict], resp_json["data"])
raise ValueError(f"Request failed: {resp_json}")
resp_data = resp_json["data"]
for elt in resp_data:
if "bboxes" in elt:
elt["bboxes"] = [
normalize_bbox(box, image_size) for box in elt["bboxes"]
]
return cast(List[Dict], resp_data)


class GroundingSAM(ImageTool):
Expand Down
Loading