Beyond text

5. Beyond text#

NLP-LLMs tend to have problems with analyzing and understanding complex structures such as tables, plots and images included in scientific articles. Since especially in chemistry and material science information about chemical components is included in these, one should think about different approaches for these structures. Therefore, vision language models (VLMs) since they can analyse images alongside text. There are several open and closed-source VLMs available, e.g., Vision models from OpenAI, Claude models and DeepSeek-VL. As an example, we show the extraction of images with GPT4-o.

First one has to convert the file into images.

Note

The used PDF file was obtained in the mining data notebook.

import matextract  # noqa: F401
from pdf2image import convert_from_path

file_path = "../obtaining_data/PDFs/10.26434_chemrxiv-2024-1l0sn.pdf"

# converting the PDF files to images
pdf_images = convert_from_path(file_path)

After that, one should process the obtained images, for instance, rotate pages with vertical text since many models have problems with this.

import imutils
import cv2
import os
import base64
from PIL import Image
import numpy as np


# most VLM models struggle with rotated text therefore, rotated text gets detect and the pages flipped
def correct_text_orientation(image, save_directory, file_path, i):
    if isinstance(image, Image.Image):
        image = pil_to_cv2(image)

    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    # This applies Canny edge detection to our grayscale image, with lower threshold 50, upper threshold 150, and an aperture size of 3.
    edges = cv2.Canny(gray, 50, 150, apertureSize=3)

    # find lines in the edge-detected image, which often correspond to text lines or document edges
    lines = cv2.HoughLinesP(
        edges, 1, np.pi / 180, 100, minLineLength=100, maxLineGap=10
    )

    angles = []
    for line in lines:
        x1, y1, x2, y2 = line[0]
        # The slope of a line is essentially rise over run, or (y2-y1)/(x2-x1). This ratio is exactly what arctan converts into an angle
        angle = np.arctan2(y2 - y1, x2 - x1) * 180.0 / np.pi
        angles.append(angle)

    # Find the dominant angle
    dominant_angle = np.median(angles)

    # Determine if the image needs to be rotated 90, 180, or 270 degrees
    if abs(dominant_angle) < 45:
        rotation_angle = 0
    elif 45 <= dominant_angle < 135:
        rotation_angle = 90
    elif -135 <= dominant_angle < -45:
        rotation_angle = -90
    else:
        rotation_angle = 180

    rotated = imutils.rotate_bound(image, angle=rotation_angle)

    base_filename = os.path.basename(file_path)
    name_without_ext, _ = os.path.splitext(base_filename)
    new_filename = os.path.join(
        save_directory, f"corrected_{name_without_ext}_page{i+1}.png"
    )
    cv2.imwrite(new_filename, rotated)
    print(f"[INFO] {file_path} - corrected image saved as {new_filename}")
    return rotated


# the images get converted into jpeg format
def convert_to_jpeg(cv2_image):
    retval, buffer = cv2.imencode(".jpg", cv2_image)
    if retval:
        return buffer


# conversion of the images from a python-image-library object to an OpenCV object
def pil_to_cv2(image):
    np_image = np.array(image)
    cv2_image = cv2.cvtColor(np_image, cv2.COLOR_RGB2BGR)
    return cv2_image


# the images get resized to a unified size with a maximum dimensions
def resize_image(image, max_dimension):
    width, height = image.size

    # Check if the image has a palette and convert it to true color mode
    if image.mode == "P":
        if "transparency" in image.info:
            image = image.convert("RGBA")
        else:
            image = image.convert("RGB")
    # convert to black and white
    image = image.convert("L")

    if width > max_dimension or height > max_dimension:
        if width > height:
            new_width = max_dimension
            new_height = int(height * (max_dimension / width))
        else:
            new_height = max_dimension
            new_width = int(width * (max_dimension / height))
        image = image.resize((new_width, new_height), Image.LANCZOS)

    return image

Next, one has to convert the pictures into machine-readable Base-64 format.

# process the images to a unified and for an VLM better suiting format
def process_image(image, max_size, output_folder, file_path, i):
    width, height = image.size
    resized_image = resize_image(image, max_size)
    rotate_image = correct_text_orientation(resized_image, output_folder, file_path, i)
    jpeg_image = convert_to_jpeg(rotate_image)
    base64_encoded_image = base64.b64encode(jpeg_image).decode("utf-8")
    return (
        base64_encoded_image,
        max(width, height),
    )


output_folder_images = "./images"

# all images get preprocessed
images_base64 = [
    process_image(image, 2048, output_folder_images, file_path, j)[0]
    for j, image in enumerate(pdf_images)
]
[INFO] ../obtaining_data/PDFs/10.26434_chemrxiv-2024-1l0sn.pdf - corrected image saved as ./images/corrected_10.26434_chemrxiv-2024-1l0sn_page1.png
[INFO] ../obtaining_data/PDFs/10.26434_chemrxiv-2024-1l0sn.pdf - corrected image saved as ./images/corrected_10.26434_chemrxiv-2024-1l0sn_page2.png
[INFO] ../obtaining_data/PDFs/10.26434_chemrxiv-2024-1l0sn.pdf - corrected image saved as ./images/corrected_10.26434_chemrxiv-2024-1l0sn_page3.png
[INFO] ../obtaining_data/PDFs/10.26434_chemrxiv-2024-1l0sn.pdf - corrected image saved as ./images/corrected_10.26434_chemrxiv-2024-1l0sn_page4.png
[INFO] ../obtaining_data/PDFs/10.26434_chemrxiv-2024-1l0sn.pdf - corrected image saved as ./images/corrected_10.26434_chemrxiv-2024-1l0sn_page5.png
[INFO] ../obtaining_data/PDFs/10.26434_chemrxiv-2024-1l0sn.pdf - corrected image saved as ./images/corrected_10.26434_chemrxiv-2024-1l0sn_page6.png

As a next step, one could call the OpenAI API. Therefore, one needs an API-key to pay for the calls. Moreover, one needs to create the prompt including the images and the text prompt.

Prompt

This is a very simple example prompt. One should optimize and engineer the prompt before usage. For that one could use a tool like DSPy.

# the text prompt text for the model call gets defined
prompt_text = "Extract all the relevant information about Buchwald-Hartwig reactions included in these images."


# the composite prompt is put together
def get_prompt_vision_model(images_base64, prompt_text):
    content = []
    # the images get added in base64 format and in the end the text prompt will be added
    for data in images_base64:
        content.append(create_image_content(data))

    content.append({"type": "text", "text": prompt_text})
    return content


# the images get converted into base64 format
def create_image_content(image, detail="high"):
    return {
        "type": "image_url",
        # the level of detail is set to 'high' since mostly text on the images is small
        "image_url": {"url": f"data:image/jpeg;base64,{image}", "detail": detail},
    }


# the composite prompt for the model call gets defined
prompt = get_prompt_vision_model(images_base64, prompt_text)

To call the actual model one could use LiteLLM instead of directly using an API like the OpenAI-API. So one could easily switch between different models for different providers.

from litellm import completion


# Define the function to call the LiteLLM API
def call_litellm(prompt, model="gpt-4o", temperature: float = 0.0, **kwargs):
    """Call LiteLLM model

    Args:
        prompt (str): Prompt to send to model
        model (str, optional): Name of the API. Defaults to "gpt-4o".
        temperature (float, optional): Inference temperature. Defaults to 0.

    Returns:
        dict: New data
    """
    messages = [
        {
            "role": "system",
            "content": (
                "You are a scientific assistant, extracting important information about reaction conditions "
                "out of PDFs in valid JSON format. Extract just data which you are 100% confident about the "
                "accuracy. Keep the entries short without details. Be careful with numbers."
            ),
        },
        {"role": "user", "content": prompt},
    ]

    response = completion(
        model=model,
        messages=messages,
        temperature=temperature,
        **kwargs,
    )

    # Extract and return the message content and token usage
    message_content = response["choices"][0]["message"]["content"]
    input_tokens = response["usage"]["prompt_tokens"]
    output_tokens = response["usage"]["completion_tokens"]
    return message_content, input_tokens, output_tokens


# Call the LiteLLM API and print the output and token usage
output, input_tokens, output_tokens = call_litellm(prompt=prompt)
print("Output: ", output)
print("Input tokens used:", input_tokens, "Output tokens used:", output_tokens)
Output:  Here is the extracted information about Buchwald-Hartwig reactions from the provided images:

```json
{
  "Buchwald-Hartwig Reactions": {
    "Key Step": "Cross-coupling reaction of an α-amino-BODIPY and the respective halide.",
    "Conditions": [
      {
        "Reagents": [
          "Pd(OAc)2",
          "(±)-BINAP",
          "Cs2CO3",
          "PhMe"
        ],
        "Temperature": "80 °C",
        "Time": "1.5 h"
      },
      {
        "Reagents": [
          "Pd(OAc)2",
          "(±)-BINAP",
          "Cs2CO3",
          "PhMe"
        ],
        "Temperature": "80 °C",
        "Time": "6-322 h"
      }
    ],
    "Yields": [
      {
        "Compound": "EDM-Ar-mono-NH2",
        "Yield": "47%"
      },
      {
        "Compound": "DM-Ar-mono-NH2",
        "Yield": "58%"
      },
      {
        "Compound": "Br-Ar-mono-NH2",
        "Yield": "56%"
      },
      {
        "Compound": "EDM-Ar-di",
        "Yield": "20%"
      },
      {
        "Compound": "DM-Ar-di",
        "Yield": "30%"
      },
      {
        "Compound": "Br-Ar-di",
        "Yield": "44%"
      },
      {
        "Compound": "EDM-Ar-di",
        "Yield": "66%"
      },
      {
        "Compound": "DM-Ar-di",
        "Yield": "62%"
      },
      {
        "Compound": "Br-Ar-di",
        "Yield": "45%"
      }
    ]
  }
}
```
Input tokens used: 6704 Output tokens used: 389

Tip

To get only the JSON part of the output, one could use Regex to extract this content.

Since in the article there is no experimental section provided, the model just extracted general information about the reactions. It failed with extraction the data provided in the reaction schemes. To extract this information, one should use tools presented in the agentic section.

Now one could use this structured output to build up a database of Buchwald-Hartwig-Coupling reactions.