917eedffcf
Main / Python 3.11 - Docs (push) Waiting to run
Main / Python 3.11 - Build (push) Waiting to run
Main / Python 3.11 - Lint (push) Waiting to run
Main / Python 3.11 - Style (push) Waiting to run
Main / Python 3.11 - Test (push) Waiting to run
Main / GPU CI (push) Blocked by required conditions
Main / Release (push) Blocked by required conditions
Main / Build and Push Docker Images (push) Blocked by required conditions
492 lines
19 KiB
Python
492 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
mine_tables.py - Extract tables from PDF documents and create table tests.
|
|
|
|
This script:
|
|
1. Takes a file containing S3 paths to PDF documents as input
|
|
2. For each PDF, extracts a random page and renders it to an image
|
|
3. Uses Gemini to identify tables in the rendered image
|
|
4. Extracts table content and creates table relationship tests by making a second Gemini request
|
|
that now includes the page image alongside the prompt (e.g., "Given cell with {cell_value}, which cell is directly to the left of it?")
|
|
5. Extracts the page from the PDF and saves it to an output folder
|
|
|
|
Usage:
|
|
python mine_tables.py --input_list path/to/s3_paths.txt --output_dir path/to/output --api_key your_gemini_api_key [--parallel 4]
|
|
"""
|
|
|
|
import argparse
|
|
import base64
|
|
import concurrent.futures
|
|
import os
|
|
import random
|
|
import threading
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
import boto3
|
|
import numpy as np
|
|
import pypdf
|
|
from bs4 import BeautifulSoup
|
|
from google import genai
|
|
from google.genai import types
|
|
from tqdm import tqdm
|
|
|
|
from olmocr.bench.tests import TableTest, save_tests
|
|
from olmocr.data.renderpdf import render_pdf_to_base64png
|
|
from olmocr.filter import PdfFilter
|
|
|
|
# Create a thread-safe lock for writing to the output file
|
|
file_lock = threading.Lock()
|
|
tests_lock = threading.Lock()
|
|
|
|
|
|
def download_pdf_from_s3(s3_path: str, local_path: str) -> bool:
|
|
"""
|
|
Download a PDF file from S3.
|
|
|
|
Args:
|
|
s3_path: The S3 path (s3://bucket/path/to/file.pdf)
|
|
local_path: The local path to save the file
|
|
|
|
Returns:
|
|
bool: True if download was successful, False otherwise
|
|
"""
|
|
try:
|
|
# Parse S3 path
|
|
parts = s3_path.replace("s3://", "").split("/", 1)
|
|
bucket = parts[0]
|
|
key = parts[1]
|
|
|
|
# Create S3 client
|
|
s3 = boto3.client("s3")
|
|
|
|
# Create directory if it doesn't exist
|
|
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
|
|
|
# Download file
|
|
s3.download_file(bucket, key, local_path)
|
|
return True
|
|
except Exception as e:
|
|
print(f"Error downloading {s3_path}: {str(e)}")
|
|
return False
|
|
|
|
|
|
def extract_page_from_pdf(input_path: str, output_path: str, page_num: int) -> bool:
|
|
"""
|
|
Extract a specific page from a PDF and save it as a new PDF.
|
|
|
|
Args:
|
|
input_path: Path to the input PDF
|
|
output_path: Path to save the extracted page
|
|
page_num: The page number to extract (0-indexed)
|
|
|
|
Returns:
|
|
bool: True if extraction was successful, False otherwise
|
|
"""
|
|
try:
|
|
# Ensure output directory exists
|
|
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
|
|
# Read the input PDF
|
|
reader = pypdf.PdfReader(input_path)
|
|
|
|
# Check if page number is valid
|
|
if page_num >= len(reader.pages):
|
|
print(f"Page number {page_num} out of range for {input_path} with {len(reader.pages)} pages")
|
|
return False
|
|
|
|
# Create a new PDF with just the selected page
|
|
writer = pypdf.PdfWriter()
|
|
writer.add_page(reader.pages[page_num])
|
|
|
|
# Write the output PDF
|
|
with open(output_path, "wb") as output_file:
|
|
writer.write(output_file)
|
|
|
|
return True
|
|
except Exception as e:
|
|
print(f"Error extracting page {page_num} from {input_path}: {str(e)}")
|
|
raise
|
|
|
|
|
|
def detect_tables(pdf_path: str, page_num: int, api_key: str) -> Optional[Tuple[List[np.ndarray], str]]:
|
|
"""
|
|
Use Gemini to detect tables in a rendered PDF page.
|
|
|
|
Args:
|
|
pdf_path: Path to the PDF file
|
|
page_num: The page number to analyze (0-indexed)
|
|
api_key: Gemini API key
|
|
|
|
Returns:
|
|
Optional[Tuple[List[np.ndarray], str]]:
|
|
A tuple with a list of detected tables (as numpy arrays) and the base64 string of the rendered page image.
|
|
Returns None if detection fails.
|
|
"""
|
|
# Initialize Gemini client
|
|
client = genai.Client(
|
|
api_key=api_key,
|
|
)
|
|
model = "gemini-2.0-flash"
|
|
|
|
# Render the PDF page as an image (render_pdf_to_base64png is 1-indexed)
|
|
try:
|
|
image_base64 = render_pdf_to_base64png(pdf_path, page_num=page_num + 1, target_longest_image_dim=2048)
|
|
except Exception as e:
|
|
print(f"Error rendering PDF page: {str(e)}")
|
|
return None
|
|
|
|
image_part = types.Part(inline_data=types.Blob(mime_type="image/png", data=base64.b64decode(image_base64)))
|
|
|
|
# Prepare prompt for Gemini to extract tables
|
|
contents = [
|
|
types.Content(
|
|
role="user",
|
|
parts=[
|
|
image_part,
|
|
types.Part.from_text(
|
|
text=(
|
|
"Analyze the document attached and output it in markdown format. "
|
|
"Output equations as Latex escaped with $$. "
|
|
"Output tables in HTML format that preserves the structure and content exactly, do not use <br> tags. "
|
|
"Instead of the markdown table format, be sure to output tables in HTML, even though the rest of the document is styled in markdown. "
|
|
"Output figures with just a simple markdown image placeholder."
|
|
)
|
|
),
|
|
],
|
|
),
|
|
]
|
|
|
|
generate_content_config = types.GenerateContentConfig(temperature=0.2, top_p=0.95, top_k=40, max_output_tokens=8192)
|
|
|
|
try:
|
|
# Call Gemini API
|
|
response = client.models.generate_content(model=model, contents=contents, config=generate_content_config)
|
|
|
|
if not response.candidates or len(response.candidates) == 0:
|
|
print(f"No response generated for {pdf_path} page {page_num}")
|
|
return None
|
|
|
|
if response.candidates[0].finish_reason != types.FinishReason.STOP:
|
|
print(f"Response generation incomplete for {pdf_path} page {page_num}")
|
|
return None
|
|
|
|
# Parse the response
|
|
response_text = response.candidates[0].content.parts[0].text
|
|
|
|
print(response_text)
|
|
|
|
# Parse tables from HTML
|
|
parsed_tables = []
|
|
soup = BeautifulSoup(response_text, "html.parser")
|
|
tables = soup.find_all("table")
|
|
|
|
for table in tables:
|
|
rows = table.find_all("tr")
|
|
table_data = []
|
|
for row in rows:
|
|
cells = row.find_all(["th", "td"])
|
|
row_data = [cell.get_text().strip() for cell in cells]
|
|
table_data.append(row_data)
|
|
# Ensure all rows have the same number of columns
|
|
if table_data:
|
|
max_cols = max(len(row) for row in table_data)
|
|
padded_data = [row + [""] * (max_cols - len(row)) for row in table_data]
|
|
table_array = np.array(padded_data)
|
|
parsed_tables.append(table_array)
|
|
|
|
# Return both the parsed tables and the rendered image (base64 string)
|
|
return (parsed_tables, image_base64) if parsed_tables else None
|
|
|
|
except Exception as e:
|
|
print(f"Error detecting tables in {pdf_path} page {page_num}: {str(e)}")
|
|
return None
|
|
|
|
|
|
def generate_table_tests(tables: List[np.ndarray], pdf_image: str, api_key: str, max_tests_per_table: int = 3) -> List[Dict]:
|
|
"""
|
|
Generate table tests from the detected tables by making a second Gemini request for each candidate cell.
|
|
|
|
For each candidate cell in a table, the function selects one valid relationship (e.g., "left", "up", "top_heading", etc.)
|
|
and sends a prompt to Gemini including the page image. For example:
|
|
"Given a cell in a table with value 'XYZ', please answer: which cell is directly to the left of it? Provide only the cell's text."
|
|
|
|
Args:
|
|
tables: List of tables as numpy arrays
|
|
pdf_image: Base64 string of the rendered page image
|
|
api_key: Gemini API key to use for generating relationship tests
|
|
max_tests_per_table: Maximum number of tests to generate per table
|
|
|
|
Returns:
|
|
List of table test dictionaries
|
|
"""
|
|
tests = []
|
|
# Initialize Gemini client for test queries
|
|
client = genai.Client(api_key=api_key)
|
|
model = "gemini-2.0-flash"
|
|
config = types.GenerateContentConfig(temperature=0.2, top_p=0.95, top_k=40, max_output_tokens=100)
|
|
|
|
# Mapping for relationship prompts
|
|
prompt_map = {
|
|
"up": "which cell is directly above it?",
|
|
"down": "which cell is directly below it?",
|
|
"left": "which cell is directly to the left of it?",
|
|
"right": "which cell is directly to the right of it?",
|
|
"top_heading": "what is the top heading (the heading for the column at the top of the table) for this cell?",
|
|
"left_heading": "what is the left heading (the heading for this row on the left part of the table) for this cell?",
|
|
}
|
|
|
|
# Create an image part from the rendered pdf image
|
|
image_part = types.Part(inline_data=types.Blob(mime_type="image/png", data=base64.b64decode(pdf_image)))
|
|
|
|
for table in tables:
|
|
rows, cols = table.shape
|
|
if table.size == 0 or rows < 2 or cols < 2:
|
|
continue # Skip tables that are too small
|
|
|
|
# Try up to 3x max_tests_per_table candidate cells
|
|
candidate_positions = []
|
|
for row in range(rows):
|
|
for col in range(cols):
|
|
if not table[row, col].strip():
|
|
continue
|
|
if row > 0:
|
|
candidate_positions.append((row, col, "up"))
|
|
if row < rows - 1:
|
|
candidate_positions.append((row, col, "down"))
|
|
if col > 0:
|
|
candidate_positions.append((row, col, "left"))
|
|
if col < cols - 1:
|
|
candidate_positions.append((row, col, "right"))
|
|
if row > 0:
|
|
candidate_positions.append((row, col, "top_heading"))
|
|
if col > 0:
|
|
candidate_positions.append((row, col, "left_heading"))
|
|
|
|
random.shuffle(candidate_positions)
|
|
tests_for_this_table = 0
|
|
|
|
for row, col, relationship in candidate_positions:
|
|
if tests_for_this_table >= max_tests_per_table:
|
|
break
|
|
|
|
cell_value = table[row, col].strip()
|
|
|
|
prompt = (
|
|
f"Given a cell in a table with value '{cell_value}', please answer: "
|
|
f"{prompt_map[relationship]} Provide only the cell's text or output 'null' if there is not a matching cell."
|
|
)
|
|
|
|
try:
|
|
contents = [types.Content(role="user", parts=[image_part, types.Part.from_text(text=prompt)])]
|
|
response = client.models.generate_content(model=model, contents=contents, config=config)
|
|
if not response.candidates or len(response.candidates) == 0 or response.candidates[0].finish_reason != types.FinishReason.STOP:
|
|
continue
|
|
answer_text = response.candidates[0].content.parts[0].text.strip()
|
|
if answer_text and "null" not in answer_text:
|
|
test_data = {"cell": cell_value, relationship: answer_text}
|
|
tests.append(test_data)
|
|
tests_for_this_table += 1
|
|
except Exception as e:
|
|
print(f"Error querying Gemini for cell '{cell_value}' and relationship '{relationship}': {str(e)}")
|
|
|
|
return tests
|
|
|
|
|
|
def process_pdf(s3_path: str, temp_dir: str, output_dir: str, api_key: str) -> List[TableTest]:
|
|
"""
|
|
Process a single PDF from S3.
|
|
|
|
Args:
|
|
s3_path: S3 path to the PDF
|
|
temp_dir: Directory for temporary files
|
|
output_dir: Directory for output files
|
|
api_key: Gemini API key
|
|
|
|
Returns:
|
|
List[TableTest]: List of generated table tests
|
|
"""
|
|
# Create a thread-specific temp directory to avoid conflicts
|
|
thread_id = threading.get_ident()
|
|
thread_temp_dir = os.path.join(temp_dir, f"thread_{thread_id}")
|
|
os.makedirs(thread_temp_dir, exist_ok=True)
|
|
|
|
# Extract filename from S3 path
|
|
pdf_filename = os.path.basename(s3_path)
|
|
local_pdf_path = os.path.join(thread_temp_dir, pdf_filename)
|
|
|
|
# Download PDF from S3
|
|
if not download_pdf_from_s3(s3_path, local_pdf_path):
|
|
return []
|
|
|
|
pdf_filter = PdfFilter()
|
|
|
|
if pdf_filter.filter_out_pdf(local_pdf_path):
|
|
print(f"Filtering out {pdf_filename}")
|
|
if os.path.exists(local_pdf_path):
|
|
os.remove(local_pdf_path)
|
|
return []
|
|
|
|
try:
|
|
# Read the PDF to get the number of pages
|
|
reader = pypdf.PdfReader(local_pdf_path)
|
|
num_pages = len(reader.pages)
|
|
|
|
if num_pages == 0:
|
|
print(f"PDF {pdf_filename} has no pages")
|
|
return []
|
|
|
|
all_pages = list(range(len(reader.pages)))
|
|
random.shuffle(all_pages)
|
|
|
|
local_tests = []
|
|
|
|
for page_num in all_pages:
|
|
# Detect tables and obtain the rendered image for this page
|
|
result = detect_tables(local_pdf_path, page_num, api_key)
|
|
if not result:
|
|
print(f"No tables detected in {pdf_filename} page {page_num+1}")
|
|
continue
|
|
|
|
tables, image_base64 = result
|
|
|
|
# Generate table tests using the new Gemini query approach with the page image
|
|
table_tests_data = generate_table_tests(tables, image_base64, api_key, max_tests_per_table=5)
|
|
|
|
if not table_tests_data:
|
|
print(f"Could not generate valid tests for tables in {pdf_filename} page {page_num+1}")
|
|
continue
|
|
|
|
# Extract the page and save to output dir
|
|
pdf_basename = os.path.splitext(pdf_filename)[0]
|
|
output_pdf_path = os.path.join(output_dir, "pdfs", f"{pdf_basename}_pg{page_num+1}.pdf")
|
|
with file_lock: # Use lock when writing to shared output directory
|
|
extract_page_from_pdf(local_pdf_path, output_pdf_path, page_num)
|
|
|
|
# Create table tests
|
|
for i, test_data in enumerate(table_tests_data):
|
|
test_id = f"{pdf_basename}_pg{page_num+1}_table_{i:02d}"
|
|
test = TableTest(
|
|
id=test_id,
|
|
pdf=f"{pdf_basename}_pg{page_num+1}.pdf",
|
|
page=1, # The extracted PDF has only one page
|
|
type="table",
|
|
cell=test_data["cell"],
|
|
url=s3_path, # Added the S3 path as the url field
|
|
up=test_data.get("up", None),
|
|
down=test_data.get("down", None),
|
|
left=test_data.get("left", None),
|
|
right=test_data.get("right", None),
|
|
top_heading=test_data.get("top_heading", None),
|
|
left_heading=test_data.get("left_heading", None),
|
|
)
|
|
local_tests.append(test)
|
|
|
|
print(f"Processed {pdf_filename} page {page_num+1}, found {len(tables)} tables, created {len(table_tests_data)} tests")
|
|
break # Process only one page per PDF
|
|
|
|
return local_tests
|
|
|
|
except Exception as e:
|
|
print(f"Error processing {pdf_filename}: {str(e)}")
|
|
return []
|
|
finally:
|
|
# Cleanup
|
|
if os.path.exists(local_pdf_path):
|
|
os.remove(local_pdf_path)
|
|
|
|
|
|
def process_pdfs_parallel(s3_paths: List[str], temp_dir: str, output_dir: str, api_key: str, max_tests: int, num_workers: int):
|
|
"""
|
|
Process PDFs in parallel using a thread pool.
|
|
|
|
Args:
|
|
s3_paths: List of S3 paths to PDFs
|
|
temp_dir: Directory for temporary files
|
|
output_dir: Directory for output files
|
|
api_key: Gemini API key
|
|
max_tests: Maximum number of tests to generate
|
|
num_workers: Number of parallel workers to use
|
|
"""
|
|
# Create shared resources
|
|
all_tests = []
|
|
output_file = os.path.join(output_dir, "table_tests.jsonl")
|
|
|
|
# Create a ThreadPoolExecutor
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=num_workers) as executor:
|
|
# Submit tasks and track futures
|
|
futures = {executor.submit(process_pdf, s3_path, temp_dir, output_dir, api_key): s3_path for s3_path in s3_paths}
|
|
|
|
# Process results as they complete
|
|
for future in concurrent.futures.as_completed(futures):
|
|
s3_path = futures[future]
|
|
try:
|
|
# Get the tests produced by this worker
|
|
new_tests = future.result()
|
|
|
|
# If we got new tests, add them to our collection
|
|
if new_tests:
|
|
all_tests.extend(new_tests)
|
|
save_tests(all_tests, output_file)
|
|
print(f"Added {len(new_tests)} tests from {os.path.basename(s3_path)}, total: {len(all_tests)}")
|
|
|
|
# Check if we've reached the maximum number of tests
|
|
if len(all_tests) >= max_tests:
|
|
print(f"Reached maximum number of tests ({max_tests}), stopping")
|
|
# Cancel any pending futures
|
|
for f in futures:
|
|
if not f.done():
|
|
f.cancel()
|
|
break
|
|
|
|
except Exception as e:
|
|
print(f"Task for {os.path.basename(s3_path)} generated an exception: {e}")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Extract tables from PDF documents and create table tests")
|
|
parser.add_argument("--input_list", required=True, help="Path to a file containing S3 paths to PDFs")
|
|
parser.add_argument("--output_dir", required=True, help="Directory to store extracted pages and tests")
|
|
parser.add_argument("--api_key", help="Gemini API key (if not provided, will use GEMINI_API_KEY environment variable)")
|
|
parser.add_argument("--temp_dir", default="/tmp/mine_tables", help="Directory for temporary files")
|
|
parser.add_argument("--max_tests", type=int, default=100, help="Maximum number of tests to generate")
|
|
parser.add_argument("--parallel", type=int, default=1, help="Number of parallel threads to use")
|
|
args = parser.parse_args()
|
|
|
|
# Get API key
|
|
api_key = args.api_key or os.environ.get("GEMINI_API_KEY")
|
|
if not api_key:
|
|
print("Error: Gemini API key not provided. Use --api_key or set GEMINI_API_KEY environment variable.")
|
|
return
|
|
|
|
os.makedirs(args.temp_dir, exist_ok=True)
|
|
os.makedirs(os.path.join(args.output_dir, "pdfs"), exist_ok=True)
|
|
|
|
# Reservoir sampling implementation
|
|
s3_paths = []
|
|
with open(args.input_list, "r") as f:
|
|
for i, line in enumerate(tqdm(f)):
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
|
|
if i < 100000:
|
|
s3_paths.append(line)
|
|
else:
|
|
# Randomly replace elements with decreasing probability
|
|
j = random.randint(0, i)
|
|
if j < 100000:
|
|
s3_paths[j] = line
|
|
|
|
print(f"Found {len(s3_paths)} PDF paths in input list")
|
|
|
|
# Determine number of workers to use
|
|
num_workers = max(1, min(args.parallel, len(s3_paths)))
|
|
print(f"Processing PDFs using {num_workers} parallel workers")
|
|
|
|
# Process PDFs in parallel
|
|
process_pdfs_parallel(s3_paths, args.temp_dir, args.output_dir, api_key, args.max_tests, num_workers)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|