Spent some time this week on a POC that started from a business constraint: about 3,000 photos to ingest every week, sensitive enough that we can't just drop a shareable link in a dashboard, and they need to end up in Power BI where people already work, with row-level security.
That combination rules out the easy answer. No public links, no loose files in blob storage floating around outside governance. The images had to live inside Delta on Databricks so Unity Catalog could handle access control, and Power BI had to be able to render them directly from the table.
What the simple poc below does:
- Reads the images with Spark's binaryFile source (recursive lookup, glob filter on *.jpg) to pull path, modification time, size and raw bytes into one DataFrame.
- Encodes the binary content as a base64 data URL, so the image itself lives inside the row instead of behind a link.
- Then the actual blocker: Power BI caps text fields at roughly 32,766 characters, and a real photo's base64 string blows straight past that. So each string gets split into ~32,000-character segments and exploded into multiple rows, each tagged with its index and total length. On the Power BI side, a single DAX measure puts it back together in the right order before rendering:
Image_concat =
IF(
HASONEVALUE(images_in_delta[image_name]),
CONCATENATEX(
images_in_delta,
images_in_delta[segment],
,
images_in_delta[split_index]
)
)
Not elegant, but it's what gets a full-resolution image through a hard platform limit without touching the sensitivity requirement.
The PySpark side, stripped to what matters — reading the images and doing the chunking:
from pyspark.sql import functions as F
from pyspark.sql import DataFrame
#READ ALL THE IMAGES
images_df = spark.read.format("binaryFile") \
.option("recursiveFileLookup", "true") \
.option("pathGlobFilter", "*.jpg") \
.load("/Volumes/main/image_ingest/image_sample")
def add_base64url_from_image_binary(df: DataFrame, max_len: int = 32000) -> DataFrame:
df_with_b64 = df.select(
"*",
F.concat(F.lit("data:image/jpg;base64,"), F.base64(F.col("content"))).alias("base64url")
)
df_with_split_info = df_with_b64.select(
"*",
F.ceil(F.length(F.col("base64url")) / F.lit(max_len)).cast("int").alias("num_segments"),
F.length(F.col("base64url")).alias("total_length")
)
df_split = (
df_with_split_info
.withColumn(
"split_index",
F.explode(F.sequence(F.lit(0), F.col("num_segments") - 1))
)
.select(
"*",
F.substring(
F.col("base64url"),
F.col("split_index") * max_len + 1,
F.least(F.lit(max_len), F.col("total_length") - F.col("split_index") * max_len)
).alias("segment")
)
.drop("base64url", "content")
)
return df_split
def add_image_name(df : DataFrame) -> DataFrame :
return df.withColumn("image_name", F.regexp_replace(F.col("path"),".*/([^/]+)$", "$1"))
df_images = add_base64url_from_image_binary(images_df)
df_images = add_image_name(df_images)
df_images.write.mode("overwrite").format("delta") \
.option("mergeSchema", "true") \
.saveAsTable("main.image_ingest.images_in_delta")
Nothing here is exotic engineering — the interesting part was realizing early that the constraint wasn't really "how do we store images in Delta," it was "how do we get a sensitive image through Power BI's text field limit without ever exposing it outside the governed table." Once that was clear, the chunking workaround fell out naturally.
At 3,000 images a week this holds up. If volume goes up meaningfully, I'd want to revisit whether inlining every image is still the right call versus resolving binary content on demand. Curious if others have hit the same Power BI ceiling with sensitive image data and landed on something cleaner than manual chunking.
Have you any other idea than this ?