Skip to content

Splitting a Giant Logo Wall into 113 Clean Tiles

Adityo Guni Waluyo

How I cut one client-logo wall raster into 113 uniform tiles: whitespace segmentation, a 220x130 canvas, and a stable next/image grid.

TL;DR

A single giant client-logo raster gets split deterministically using whitespace projection and connected components, no machine learning needed. Each crop is normalized onto a 220x130 white canvas so the grid stays tidy without stretching. The frontend uses next/image with explicit dimensions, lazy loading, and slot-based naming to prevent layout shift and keep pruning safe.

I opened the Network tab, scrolled down to page 21 of a company profile, and there it was: one giant client-logo wall raster file. My initial guess was that breaking this up would hurt performance because "many small files must be slower." That is just HTTP/1.1 folklore. In reality, HTTP/2 multiplexing handles multiple small requests effortlessly over a single connection, completely refuting that old assumption [4]. The actual, concrete reasons to tile this image are layout stability, lazy loading, and responsive sizes.

One raster file also forces every visitor to download the whole wall, including the logos they never scroll to. And without explicit dimensions in the HTML, the page jumps around while the image loads. Here is how I rebuilt it.

Segmentation Without Machine Learning

You do not need a heavy machine learning model to split a logo wall. The most reliable approach is segmentation by whitespace projection. This relies on classic recursive X-Y cut or projection profile cuts, a technique that has been proven effective for document and image segmentation for decades [5].

By analyzing the image matrix, we can find the natural gaps between logos. Once the bounding boxes are identified, I use connected components via skimage.measure.label to isolate each logo cleanly [6]. There is no need for neural networks or complex training data. It is just straightforward, deterministic image processing that runs in milliseconds on a standard server, keeping the pipeline fast and predictable.

Before I trust any automated cut, I run a cheap probe: print the non-white pixel count for every row. A run of zeros between two content blocks is a healthy cut line.

from PIL import Image

im = Image.open("wall.png").convert("L")
w, h = im.size
px = im.load()
for y in range(0, h, 2):
    nonwhite = sum(1 for x in range(0, w, 2) if px[x, y] < 245)
    print(y, nonwhite)

If the zeros never appear in clean bands, the threshold caught noise. Clean the image or raise the threshold before labeling, or the cuts will slice through logos instead of the whitespace around them.

Normalizing the Crops

After cutting, the individual logos have wildly different aspect ratios. Some are wide, some are tall, and some are nearly square. If you drop them directly into a grid, the layout will look messy and unprofessional.

To fix this, I normalize every crop onto a uniform 220x130 white canvas using Pillow's ImageOps.pad [1]. This guarantees that every logo is centered with its original aspect ratio intact, surrounded by clean white space. It prevents any stretching, squashing, or aggressive cropping that would ruin a client's branding. With this step, the frontend accepts any asset without manual adjustment; without it, the grid's tidiness depends on luck.

The Frontend Contract

On the frontend, I enforce a strict contract in client-strip.tsx. A responsive ul grid adapts to 3, 5, 7, or 9 columns depending on the viewport width. Every logo is rendered using next/image with explicit width 220 and height 130. This is non-negotiable: it reserves the exact space needed in the DOM and prevents cumulative layout shift while the images load [2].

The sizes attribute tells the browser which resolution to request per breakpoint, so phones do not download desktop-sized assets; roughly 11 percent of the viewport width on desktop, 14 on tablets, 30 on phones, matching the column counts. Since these logos are purely decorative in this context, alt="" [2] plus aria-hidden is enough; a screen reader has no business reciting 113 company names. Finally, loading="lazy" [3] defers fetching the tiles below the fold until the user scrolls near them.

The Quality Gate

Automated slicing is rarely perfect on the first run. My first pass produced 196 tiles. After review, that number dropped to 114, and finally settled at 113 valid tiles: one clipped fragment at the edge was dropped, and one duplicate was dropped. I did not force the rest in.

This is where slot-based numbering saves the day. Tiles are named by their absolute grid slot, not by detection order. If tile 45 is dropped, tile 46 does not suddenly become tile 45; the neighbors never shift, and the grid keeps its shape. That little detail is what keeps a pruned set from turning into mysterious rendering bugs later.

The end state is simple: the single giant image is not coming back. What changed is who controls the sizing. The browser does now, not the file.

## Sources [1] https://pillow.readthedocs.io/en/stable/reference/ImageOps.html [2] https://nextjs.org/docs/app/api-reference/components/image [3] https://developer.mozilla.org/en-US/docs/Web/Performance/Guides/Lazy_loading [4] https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Evolution_of_HTTP [5] https://www.haralick.org/conferences/71280952.pdf [6] https://scikit-image.org/docs/dev/api/skimage.measure.html

Related articles