-
Notifications
You must be signed in to change notification settings - Fork 58
feat: support multiple images in conversations and add PDF-to-image utility #313
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
Draft
fcarli
wants to merge
8
commits into
staging
Choose a base branch
from
dev-images
base: staging
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
f1c77ac
Add utils function to parse pdf into immages
fcarli 3093408
Extend conversation to allow multiple images in input
fcarli 49e199f
Update tests for new conversation behavior
fcarli 82012a9
Update docs to document new PDF feature
fcarli 79787f1
Merge branch 'main' into dev-images
fcarli 5033667
Merge branch 'main' into dev-images
slobentanzer 836be7f
Merge branch 'main' into dev-images
slobentanzer f17f0a7
Merge branch 'main' into dev-images
slobentanzer File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -501,10 +501,10 @@ def append_user_message(self, message: str) -> None: | |
| def append_image_message( | ||
| self, | ||
| message: str, | ||
| image_url: str, | ||
| image_url: str | list[str], | ||
| local: bool = False, | ||
| ) -> None: | ||
| """Add a user message with an image to the conversation. | ||
| """Add a user message with one or more images to the conversation. | ||
|
|
||
| Also checks, in addition to the `local` flag, if the image URL is a | ||
| local file path. If it is local, the image will be encoded as a base64 | ||
|
|
@@ -513,25 +513,32 @@ def append_image_message( | |
| Args: | ||
| ---- | ||
| message (str): The message from the user. | ||
| image_url (str): The URL of the image. | ||
| local (bool): Whether the image is local or not. If local, it will | ||
| be encoded as a base64 string to be passed to the LLM. | ||
| image_url (str | list[str]): The URL(s) of the image(s). Can be a single | ||
| URL string or a list of URL strings for multiple images. | ||
| local (bool): Whether the image(s) are local or not. If local, they will | ||
| be encoded as base64 strings to be passed to the LLM. | ||
|
|
||
| """ | ||
| parsed_url = urllib.parse.urlparse(image_url) | ||
| if local or not parsed_url.netloc: | ||
| image_url = f"data:image/jpeg;base64,{encode_image(image_url)}" | ||
| # Ensure image_url is always a list for consistent processing | ||
| if isinstance(image_url, str): | ||
| image_urls = [image_url] | ||
| else: | ||
| image_url = f"data:image/jpeg;base64,{encode_image_from_url(image_url)}" | ||
| image_urls = image_url | ||
|
|
||
| self.messages.append( | ||
| HumanMessage( | ||
| content=[ | ||
| {"type": "text", "text": message}, | ||
| {"type": "image_url", "image_url": {"url": image_url}}, | ||
| ], | ||
| ), | ||
| ) | ||
| # Build the content list starting with the text message | ||
| content = [{"type": "text", "text": message}] | ||
|
|
||
| # Process each image and add to content | ||
| for url in image_urls: | ||
| parsed_url = urllib.parse.urlparse(url) | ||
| if local or not parsed_url.netloc: | ||
| encoded_url = f"data:image/jpeg;base64,{encode_image(url)}" | ||
| else: | ||
| encoded_url = f"data:image/jpeg;base64,{encode_image_from_url(url)}" | ||
|
|
||
| content.append({"type": "image_url", "image_url": {"url": encoded_url}}) | ||
|
|
||
| self.messages.append(HumanMessage(content=content)) | ||
|
|
||
| def setup(self, context: str) -> None: | ||
| """Set up the conversation with general prompts and a context.""" | ||
|
|
@@ -565,7 +572,8 @@ def setup_data_input_tool(self, df, input_file_name: str) -> None: | |
| def query( | ||
| self, | ||
| text: str, | ||
| image_url: str | None = None, | ||
| image_url: str | list[str] | None = None, | ||
| local: bool = False, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe the param name should refer to the image-related function, otherwise could be unclear (this is the generic query function, and params typically refer to the purpose of the method). Call it |
||
| structured_model: BaseModel | None = None, | ||
| wrap_structured_output: bool | None = None, | ||
| tools: list[Callable] | None = None, | ||
|
|
@@ -588,8 +596,13 @@ def query( | |
| ---- | ||
| text (str): The user query. | ||
|
|
||
| image_url (str): The URL of an image to include in the conversation. | ||
| Optional and only supported for models with vision capabilities. | ||
| image_url (str | list[str] | None): The URL(s) of image(s) to include | ||
| in the conversation. Can be a single URL string, a list of URL strings | ||
| for multiple images, or None. Optional and only supported for models | ||
| with vision capabilities. | ||
|
|
||
| local (bool): Whether the image(s) are local files or not. If True, | ||
| images will be encoded as base64 strings. Defaults to False. | ||
|
|
||
| structured_model (BaseModel): The structured output model to use for the query. | ||
|
|
||
|
|
@@ -648,7 +661,7 @@ def query( | |
| if not image_url: | ||
| self.append_user_message(text) | ||
| else: | ||
| self.append_image_message(text, image_url) | ||
| self.append_image_message(text, image_url, local=local) | ||
|
|
||
| self._inject_context(text) | ||
|
|
||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| import io | ||
| import logging | ||
| from pathlib import Path | ||
|
|
||
| import fitz # PyMuPDF | ||
| from PIL import Image | ||
|
|
||
| # Configure logging | ||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def pdf_pages_to_images( | ||
| pdf_path: str | Path, | ||
| destination_folder: str | Path, | ||
| image_format: str = "PNG", | ||
| dpi: int = 300, | ||
| prefix: str = "page", | ||
| ) -> list[str]: | ||
| """Extract all pages from a PDF file and save them as separate image files. | ||
|
|
||
| Args: | ||
| pdf_path: Path to the input PDF file | ||
| destination_folder: Path to the folder where images will be saved | ||
| image_format: Image format to save (PNG, JPEG, etc.). Defaults to PNG | ||
| dpi: Resolution for the output images. Defaults to 150 | ||
| prefix: Prefix for the output image filenames. Defaults to "page" | ||
|
|
||
| Returns: | ||
| List of paths to the created image files | ||
|
|
||
| Raises: | ||
| FileNotFoundError: If the PDF file doesn't exist | ||
| ValueError: If the PDF file is invalid or corrupted | ||
| OSError: If there's an error creating the destination folder or saving images | ||
|
|
||
| """ | ||
| pdf_path = Path(pdf_path) | ||
| destination_folder = Path(destination_folder) | ||
|
|
||
| # Validate input PDF file | ||
| if not pdf_path.exists(): | ||
| raise FileNotFoundError(f"PDF file not found: {pdf_path}") | ||
|
|
||
| if not pdf_path.suffix.lower() == ".pdf": | ||
| raise ValueError(f"File is not a PDF: {pdf_path}") | ||
|
|
||
| # Create destination folder if it doesn't exist | ||
| try: | ||
| destination_folder.mkdir(parents=True, exist_ok=True) | ||
| except OSError as e: | ||
| logger.error(f"Error creating destination folder {destination_folder}: {e}") | ||
| raise | ||
|
|
||
| created_files = [] | ||
|
|
||
| try: | ||
| # Open the PDF document | ||
| pdf_document = fitz.open(pdf_path) | ||
|
|
||
| logger.info(f"Processing PDF with {len(pdf_document)} pages") | ||
|
|
||
| # Extract each page as an image | ||
| for page_num in range(len(pdf_document)): | ||
| try: | ||
| # Get the page | ||
| page = pdf_document[page_num] | ||
|
|
||
| # Create a transformation matrix for the desired DPI | ||
| zoom = dpi / 72 # Convert DPI to zoom factor | ||
| mat = fitz.Matrix(zoom, zoom) | ||
|
|
||
| # Render page to an image (using correct PyMuPDF API) | ||
| pix = page.get_pixmap(matrix=mat) # type: ignore[attr-defined] | ||
|
|
||
| # Convert pixmap to bytes and then to PIL Image | ||
| img_data = pix.tobytes("png") | ||
| img = Image.open(io.BytesIO(img_data)) | ||
|
|
||
| # Generate filename with zero-padded page numbers | ||
| total_pages = len(pdf_document) | ||
| padding = len(str(total_pages)) | ||
| filename = f"{prefix}_{page_num + 1:0{padding}d}.{image_format.lower()}" | ||
| output_path = destination_folder / filename | ||
|
|
||
| # Save the image | ||
| img.save(output_path, format=image_format.upper()) | ||
| created_files.append(str(output_path)) | ||
|
|
||
| logger.debug(f"Saved page {page_num + 1} as {output_path}") | ||
|
|
||
| except Exception as e: | ||
| logger.error(f"Error processing page {page_num + 1}: {e}") | ||
| continue | ||
|
|
||
| pdf_document.close() | ||
|
|
||
| except Exception as e: | ||
| logger.error(f"Error opening PDF file {pdf_path}: {e}") | ||
| raise ValueError(f"Invalid or corrupted PDF file: {pdf_path}") from e | ||
|
|
||
| logger.info(f"Successfully extracted {len(created_files)} pages from {pdf_path}") | ||
| return created_files |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
enforce accepting only image_urls: str | list[str] (either web urls or base64 encoded0, and also raise errors if incorrect. The whole concept of "local files" shouldn't exist at this level and providing the correct url should be handled by the UI layer.