Luke
04/06/2025, 1:34 AMprocessed_image node)?
• I don’t love some of my node names and abstractions — each_image, processed_image -> processed_images 😐. Any recommendations?
◦ This expansion is especially weird and points to poor naming: list(_processed_image["preprocessed_image"] for _processed_image in processed_image)
I’ve collapsed the image processing steps into one node, processed_image. I’d rather explode this into preprocessed_image, tesseract_output, tesseract_summary, etc… Ideally, the results of each of these stages could be accessed without further processing before all results are Collected. With that in mind, I realize the current implementation is probably fine for this level of the problem. However, I’ll need a different solution for the “meta-scale” problem — processing thousands of PDFs. Within this context, I’ll need to queue, monitor, and save artifacts for each PDF individually. I wont be collapsing all processing into a single node as I’ve done here. I suspect this is where Hamilton’s scope ends and a full-featured Orchestrator is preferred. Is this correct?
DAG graph is attached. DAG definition below.
# dag.py
from typing import Union
from pathlib import Path
from PIL import Image
import pandas as pd
from hamilton.function_modifiers import source, extract_fields
# These are excluded from the DAG
from utils import (
pdf_to_images,
preprocess_image,
image_to_tesseract_data,
TesseractDocumentSummary,
)
from hamilton.htypes import Parallelizable, Collect
import numpy as np
@source
def pdf_path() -> Union[str, Path]:
pass
def pdf_images(pdf_path: Union[str, Path], kwargs: dict = {}) -> list[Image.Image]:
default_kwargs = {"dpi": 300, "thread_count": 4}
kwargs = {**default_kwargs, **kwargs}
return pdf_to_images(pdf_path, **kwargs)
def each_image(pdf_images: list[Image.Image]) -> Parallelizable[Image.Image]:
for image in pdf_images:
yield image
def processed_image(each_image: Image.Image) -> dict:
preprocessed_image = preprocess_image(each_image)
tesseract_output = image_to_tesseract_data(preprocessed_image)
difficulty_summary = TesseractDocumentSummary.from_tesseract_data(tesseract_output)
return {
"preprocessed_image": preprocessed_image,
"tesseract_output": tesseract_output,
"difficulty_summary": difficulty_summary,
}
@extract_fields(
{
"preprocessed_images": list[np.ndarray],
"tesseract_outputs": pd.DataFrame,
"difficulty_table": pd.DataFrame,
}
)
def processed_images(
processed_image: Collect[dict],
) -> dict:
return {
"preprocessed_images": list(
_processed_image["preprocessed_image"]
for _processed_image in processed_image
),
"tesseract_outputs": pd.concat(
[
_processed_image["tesseract_output"]
for _processed_image in processed_image
],
ignore_index=True,
),
"difficulty_table": pd.DataFrame(
[
_processed_image["difficulty_summary"]
for _processed_image in processed_image
]
),
}Elijah Ben Izzy
04/07/2025, 2:35 AMParallelizable and Collect have a ton of customizability that can probably help you with your execution problems
At a high-level, you’ll want to implement (or use a prebuilt) TaskExecutor. Then you’ll want to call with_remote_executor and pass an instance in.
The cool part about this is that you can bridge with any other execution system you want — E.G. a fancy one with queueing, etc… For instance, if you have batched inputs on ray at high throughput using the GPU, all you’d have to do is implement the submit_task method to submit it to a queue that gives you a future.
This also allows you to interact with any orchestrator/executor technology with a programmatic python API. All that said, my guess is that something like Ray for execution. See this blog post for more. The only caveat is that I’d make the subdags (between parallel and collect) reseponsible for loading up the image data, processing it, then saving it out, and passing pointers. That way the central orchestrator is working with pointers, rather than large in-memory objects. S3 or a NFS is a simple solution here.Stefan Krawczyk
04/07/2025, 3:41 AMprocessed_image could internally be calling another Hamilton DAG; this is effectively what subdag (see the docs) is syntactic sugar for.Thierry Jean
04/07/2025, 8:02 PMParallelizable/Collect you can split operations in several nodes to process "one document at a time". Then, your Collect node can collect multiple "inside nodes". You can't have multiple Collect nodes though, i.e., having processed_image_collection and tessteract_output_collection would be invalid
from hamilton.function_modifiers import extract_fields
from hamilton.htypes import Collect, Parallelizable
import pandas as pd
import numpy as np
def pdf_image() -> Parallelizable[np]:
default_kwargs = {"dpi": 300, "thread_count": 4}
kwargs = {**default_kwargs, **kwargs}
for image in pdf_to_images(pdf_path, **kwargs):
yield image
def preprocessed_image(pdf_image: str) -> np.ndarray:
return preprocess_image(each_image)
def tesseract_output(preprocessed_image: np.ndarray) -> str:
return image_to_tesseract_data(preprocessed_image)
def difficulty_summary(tesseract_output: str) -> float:
return TesseractDocumentSummary.from_tesseract_data(tesseract_output)
@extract_fields(
{
"preprocessed_images": list[np.ndarray],
"tesseract_outputs": dict,
"difficulty_table": dict,
}
)
def pdf_image_collection(
preprocessed_image: Collect[np.ndarray],
tesseract_output: Collect[str],
difficulty_summary: Collect[float],
) -> dict:
return {
"preprocessed_images": preprocessed_image, # is a list by default
"tesseract_outputs": pd.concat(tesseract_output, ignore_index=True),
"difficulty_table": pd.DataFrame(difficulty_summary),
}
import functions
from hamilton import driver, base
dr = (
driver.Builder()
.with_modules(functions)
.build()
)
dr.display_all_functions(orient="TB", show_legend=False)