# Cut shoes out of photos so they stand exactly on a shelf: a background removal pipeline and traps

> Why a shoe wall needs side-view cut-outs, where usable photographs come from, and a Python pipeline that keys, mattes, cleans the edge, levels the sole and measures where it meets the shelf.

Behind the build, by [Ramin Valadan](https://upfork.dev/developers/ramin-valadan), about [Pacewall — Sneaker & Shoe Store Product Grid for React and Tailwind CSS](https://upfork.dev/products/shoe-store-product-grid-react) (Component, Free). Published 2026-11-05, updated 2026-11-05. 18 min read. Written with AI: Claude Opus 5.5, through Claude.

## What it is

Pacewall is an animated product grid for shoe stores, in which every shoe stands on a slim shelf and flies across the wall when the order changes. That only works with transparent, side-view pictures whose sole line is known to a fraction of a pixel. This article is about the Python pipeline that makes them: why the wall needs them, how usable pictures were found, and each step from a source picture to the numbers the component reads, with what went wrong at each one.

[Live preview of Pacewall — Sneaker & Shoe Store Product Grid for React and Tailwind CSS](https://upfork.dev/products/shoe-store-product-grid-react)

Be clear about what the pictures are. The demo holds 17 shoes: **seven real photographs, kept from 888 looked at, and ten AI-generated images.** Each one is listed in `PHOTOS.md` with its source, licence or tool, and edits. None of them falls under the product's MIT licence, and a shop should replace them with its own.

The pipeline is not part of the installable component, so the code below is in plain fences. All of it is in the [public source](https://github.com/upfork-dev/free-components/tree/main/pacewall), under `scripts/photos`.

## Why the wall needs side-view cut-outs

A product card can crop any photograph into a rectangle. This wall cannot, for four reasons:

- **It has no background of its own.** A shoe passes in front of other shelves and sits on a light or a dark wall, so it needs real transparency, with no fringe of the old backdrop on either theme.
- **It reads as a row.** Side views with the toe to the left line up, and a three-quarter view next to a profile does not.
- **Every sole must match.** Drawn at one width, shoes must have the same sole length. A high-top is made smaller on purpose, by a `scale` setting.
- **It stands on a line.** Every picture has a different amount of space under the sole, so that amount has to be measured.

The component reads the result as plain numbers:

`components/demo/photos.ts`, lines 4–17:

```ts
export type PhotoEntry = {
  id: string;
  width: number;
  height: number;
  /** 0..1 from the top: where the sole meets the shelf. */
  baseline: number;
  /** 0..1: the part of the sole that touches the shelf. */
  contact: [number, number];
  hue: number;
  lightness: number;
  lqip: string;
  /** Widest first is last; every size exists in /shoes/. */
  sizes: number[];
};
```

`baseline` is where the sole ends, as a share of the picture's height. The empty part below it becomes a negative margin, so the bottom of the box is the sole. `contact` is the part of the sole's width that touches the shelf, and it places the contact shadow:

`components/shoe-wall/Slot.tsx`, lines 18–21:

```tsx
export function dropOf(shoe: Shoe): string {
  const { baseline, height, width } = shoe.photo;
  return `${((1 - baseline) * height * 100) / width}%`;
}
```

`components/shoe-wall/Slot.tsx`, lines 42–58:

```tsx
      <div className="pw-stage">
        <span className="pw-downlight" aria-hidden />
        <div className="pw-fly" style={{ "--pw-drop": dropOf(shoe) } as React.CSSProperties}>
          <span className="pw-wallshadow" aria-hidden />
          <span className="pw-contact" aria-hidden style={{ insetInlineStart: `${left * 100}%`, width: `${(right - left) * 100}%` }} />
          <div className="pw-lift">
            <ShoeImage
              photo={shoe.photo}
              sizes={sizes}
              index={index}
              eager={index < 8}
              high={index < 4}
              renderImage={renderImage}
            />
          </div>
        </div>
      </div>
```

So the shelf's accuracy is the measurement's accuracy. Checked in Chrome across 108 layouts, the worst sole sat 0.45 px off its shelf.

## Finding pictures you can use

The rules were: a licence that allows modifying and shipping the picture, a side view within about 20° of profile, nothing in the way, and no mark that can be read. The target was 18 shoes.

| Source | Seen by eye | Passed every rule |
| --- | --- | --- |
| Unsplash (free licence filter) | 103 | 0 |
| Pexels | 358 | 3 |
| Pixabay | 268 | 2 |
| Burst | 26 | 2 |
| Wikimedia Commons | 133 | 0 |
| **Total** | **888** | **7** |

That is about one in 130. Rejections were almost all for one of three reasons: a visible maker's mark, more than about 20° off profile, or a foot or a second shoe in the way. The count is not a full survey of those sites, because the search was stopped before it finished.

> **Lesson: Stock photo captions and 250 px thumbnails hide makers' marks: judge a shoe only at 1400 px or more** (My own mistake; learned by the AI (Claude Opus 5.5))
> **Rule:** If you need product photos with no maker's mark, ignore caption filters and thumbnails. Open every candidate at 1400 px or more, and expect to reject far more than you keep.
> **Snippet:**
> ```text
> before: filter on caption words, judge at 250 px
> after:  judge every candidate by eye at 1400 px or more
> ```
> **Check:** Open each kept photo at full size and read the quarter, the heel tab and the side: nothing can be read.
> **Applies to:** other: free stock photo sites
> **I assumed:** a filter on the caption words would remove branded shoes.
> **What happened:** captions rarely name the maker. At 250 px the thumbnails looked clean, and at 1400 px they showed a printed word on the quarter, a letter on a heel tab, and a small metal plate on the side of a shoe.
> **The fix:** judge marks only at large size, and count on rejecting most of what you open.
> **Evidence:** 888 seen, 7 passed.

> **Lesson: A side-view shoe rejected for a mark may be clean on its inner side: open the same upload's neighbouring IDs** (My own mistake; learned by the AI (Claude Opus 5.5))
> **Rule:** If a side view is rejected for a printed mark, open the neighbouring IDs of the same upload. They are often the other side of the shoe, and the inner side often carries no mark.
> **Snippet:**
> ```text
> before: shoe-1215384, outer side, a printed device: rejected, move on
> after:  shoe-1215385, the next ID, the inner side of the same trainer: no device
> ```
> **Check:** Open the neighbour at full size and read its side: nothing can be read.
> **Applies to:** other: free stock photo sites
> **I assumed:** a photograph rejected for a mark ruled out that shoe.
> **What happened:** one photographer shot the same trainer from both sides. The outer sides have a printed device, and the inner sides have none. Those inner sides are among the very few pure-profile photographs with no mark that the search found.
> **The fix:** look at the rest of an upload before leaving it.
> **Evidence:** two of the seven, `coral-runner` and `blue-runner` in `PHOTOS.md`.

> **Lesson: Single stock photos rarely meet a product rule: when one fits, search its file name and its series** (My own mistake; learned by the AI (Claude Opus 5.5))
> **Rule:** If one clean product shot appears, search its file name's words, its photographer and its neighbouring IDs. Shop-made series share one studio, one light, a plain wall and no maker's device.
> **Snippet:**
> ```text
> before: more searches by colour and kind of shoe
> after:  all-black-sneaker-right-foot leads to the rest of that studio's run
> ```
> **Check:** Look at the next results: same backdrop, same light, side views.
> **Applies to:** other: free stock photo sites
> **I assumed:** usable photographs would turn up one at a time across many searches.
> **What happened:** hundreds of images gave few clean profiles, then one search gave a run named by foot: one studio, one light, a plain wall, side views, no marks.
> **The fix:** treat one good shot as the way into a series. Series made by a shop are the ones without a maker's device.
> **Evidence:** two of the seven, `black-lace` and `mesh-runner` in `PHOTOS.md`.

Choose photographs with no mark at all, and do not edit one out. One of our seven does carry such an edit: a small printed word and device were retouched on its tongue, which `PHOTOS.md` records. It is the one edit here that we do not recommend.

Seven was not enough for a wall, and the same photographs had started to come back.

> **Lesson: Measure what a sourcing rule costs and show its owner: 888 photos gave 7, so 11 more meant about 1,400** (My own mistake; learned by the AI (Claude Opus 5.5))
> **Rule:** If a rule costs far more than planned, measure the rate and put the number in front of the rule's owner, instead of searching on or lowering the rule quietly.
> **Snippet:**
> ```text
> before: keep searching for 18, and report what was found
> after:  7 usable of 888 seen, one in 130. 11 more means about 1,400 more. Your call.
> ```
> **Check:** The report states the rate, what finishing costs at that rate, and the choices.
> **Applies to:** other: any product with sourced images
> **I assumed:** a long enough search would reach 18 free, unmarked, side-view photographs.
> **What happened:** the rule's owner, the developer, lifted "real photographs only" for the shoes still missing and generated eleven images from prompts written for it, eight with Grok and three with ChatGPT. That took about half an hour, against about two and a half hours of searching on a poor connection, so read the hours loosely. They brought colours and kinds of shoe the search never reached, and made the category filter and the colour sort useful. One was removed later: it had no mark, but it read classic and sat close to a known tennis shoe.
> **Why:** a generated image is cheap to redo or drop, and a photograph has to be found.
> **The fix:** report the rate as soon as it is known, and write down what the change costs. Here, sources of 1168×784 and 1536×1024 mean no 2000 px export and little zoom, so views exist on only five shoes. Every document had to stop saying all the shoes are real. The tools' terms for shipping their images in an MIT product are not verified, and readers are told to check them or replace the pictures.
> **Evidence:** `PHOTOS.md` labels each picture.

## Cut out: the shape first, then the edge

The planned tool was rembg with the BiRefNet model. It works at 1024 px, and these pictures have plain, known backdrops, so the pipeline does without a model: it works on sources up to 3200 px wide, and a shoe took a minute or two at most on a laptop's processor. `"method": "birefnet"` stays available for pictures with hard backgrounds.

Cutting out is two separate questions. **Which pixels are shoe at all?** is answered at a working size of at most 1500 px. **How transparent is each pixel at the edge?** is answered afterwards at higher resolution, only in a narrow band around that shape.

### The shape

Every method starts from the same model of the backdrop. Studio backdrops are not flat: they carry a gradient and a vignette. So the pipeline fits a smooth surface per channel, in LAB, through pixels known to be background, a thin ring at the picture's border plus any hand-marked areas:

`c(x, y) = a₀ + a₁x + a₂y + a₃xy + a₄x² + a₅y²`

```python
basis = np.stack([np.ones_like(xn), xn, yn, xn * yn, xn**2, yn**2], axis=-1).astype(np.float32)
picks = np.flatnonzero(known.ravel())
if picks.size > 60000:
    picks = picks[:: picks.size // 60000]
design = basis.reshape(-1, 6)[picks]
for c in range(3):
    coef, *_ = np.linalg.lstsq(design, lab[..., c].ravel()[picks], rcond=None)
    out[..., c] = basis @ coef
```

Six coefficients per channel, solved by least squares from at most 60,000 samples. Subtracting the surface leaves each pixel's difference from the backdrop. The photographs then go through a graph cut, `cv2.grabCut`, seeded from that distance: far from the backdrop is sure shoe, the border is sure background, and the cut decides what lies between.

> **Lesson: One wrong seed ruins a polynomial background fit: keep seeds on other surfaces out of the colour model** (My own mistake; learned by the AI (Claude Opus 5.5))
> **Rule:** If a dark backdrop stays on a light product after a fitted key, check the seeds: one marking another surface bends the fit. For a light product on dark, key on brightness alone.
> **Snippet:**
> ```python
> # before: a stone slab under the shoe passed as "known background"
> model = fit_background(part, known)
> # after: a light shoe on a dark, hazy backdrop
> if shoe.get("key") == "dark":
>     distance = lab[..., 0]
> ```
> **Check:** Run with `PW_SHAPE_ONLY=1`, which skips the matting, and look at the shape: backdrop out, shoe whole.
> **Applies to:** other: NumPy least squares background fit
> **I assumed:** every background seed helps the fit.
> **What happened:** the slab under a white trainer was passed as known background, so the fit bent towards its colour. The black backdrop then read as far from the background, and stayed on the shoe.
> **The fix:** keep seeds that mark a different surface out of the colour fit, and key on brightness when it says more than colour does.
> **Evidence:** the `key` setting in `scripts/photos/pipeline.py`.

The generated images have plain grey backdrops, and many of those shoes have white or grey soles. There the graph cut failed.

> **Lesson: A graph cut gives a white midsole to a light grey backdrop: ask a signed question in LAB instead** (My own mistake; learned by the AI (Claude Opus 5.5))
> **Rule:** If a graph cut drops a white part of a product on a light studio backdrop, skip it. Count a pixel as product if it is lighter, much darker or more colourful than the fitted backdrop.
> **Snippet:**
> ```python
> # before: a graph cut seeded from the distance to the backdrop
> labels = np.where(distance > t_low, cv2.GC_PR_FGD, cv2.GC_PR_BGD).astype(np.uint8)
> # after: a signed rule against the fitted backdrop, in LAB
> delta = lab - model
> chroma = np.hypot(delta[..., 1], delta[..., 2])
> shape = (delta[..., 0] > rule["light"]) | (delta[..., 0] < -rule["dark"]) | (chroma > rule["chroma"])
> ```
> **Check:** Look at the cut-out on mid-grey, a dark wall and magenta at about 250%: the midsole is whole, and the shadow has gone.
> **Applies to:** other: OpenCV grabCut
> **I assumed:** the same graph cut that worked on the photographs would work on any plain backdrop.
> **What happened:** on one shoe the whole white midsole went with the background. Its distance from the backdrop was only about 10 levels of lightness, and the colour model saw two near-neutral areas.
> **Why:** a distance has no sign, and a graph cut's colour model cannot separate two near-neutrals. The sign is what helps: a soft shadow only darkens and has no colour of its own, so a low limit for lighter and a high limit for darker keep the shadow out by themselves. The ten images use 3–8 levels for lighter, 14–16 for darker, and 5–7 for chroma.
> **The fix:** a `plain` setting with those three limits, and a stricter rule under the sole, where the backdrop carries the shadow.
> **Evidence:** all ten generated images use it, in `scripts/photos/shoes.json`.

```python
# Under the sole the backdrop carries the shoe's shadow: there it takes more to count as shoe.
low = rule.get("below")
if low:
    line = int((low["y"] - origin[1]) * small)
    shape[line:] = (
        (delta[line:, :, 0] > low.get("light", rule["light"]))
        | (delta[line:, :, 0] < -low.get("dark", rule["dark"]))
        | (chroma[line:] > low.get("chroma", rule["chroma"]))
    )
```

No threshold could finish the job on its own, and the reason is worth knowing before you try.

> **Lesson: The shaded part of a white sole has exactly the backdrop's brightness: no threshold helps, seed it by hand** (My own mistake; learned by the AI (Claude Opus 5.5))
> **Rule:** If a white sole shows holes whatever the threshold, its shaded part crosses the backdrop's value. Add a hand seed inside the sole, or make the picture on a backdrop no shoe colour crosses.
> **Snippet:**
> ```jsonc
> // before: limits only, and holes through the sole
> "plain": { "light": 3, "dark": 14, "chroma": 5 }
> // after: also a polygon, in source pixels, that is certainly shoe (shortened here)
> "fg": [[[140, 420], [995, 400], [1046, 480], [1046, 515], [800, 588], [345, 586], [130, 460]]]
> ```
> **Check:** On a dark wall and on magenta at about 250%, no wall shows through the sole.
> **Applies to:** other: LAB keying of white products on grey
> **I assumed:** the signed rule would hold every white sole.
> **What happened:** three shoes still had holes through their white soles, because somewhere a white surface turning away from the light passes through the backdrop's grey. Both sides of the threshold are the same number there.
> **The fix:** five of the eleven images needed a seed inside the sole. Asking the image tool for a backdrop no shoe colour crosses is better still: the three images on a darker grey needed no seed for the sole, and those on light grey with white or grey soles did. L-7GKR and L-105V meet the same trouble with other fixes.
> **Evidence:** the `fg` settings in `scripts/photos/shoes.json`, seen by eye on three backgrounds.

Seeds are absolute: the shape takes them after every rule, `shape = (shape | fg_seed) & ~bg_seed`, and the matting may not undo them either.

### Real holes and false ones

A pull loop or a lace gap is a real hole, and the wall must show through it. A groove in a sole is not. Getting that right took three tries.

> **Lesson: binary_fill_holes painted the backdrop into a pull loop: close only holes below a size** (My own mistake; learned by the AI (Claude Opus 5.5))
> **Rule:** If loops on a cut-out turn solid, `binary_fill_holes` closed real holes. On a shoe with loops and no mesh, close only holes below a size. On a mesh upper that limit fails: use L-ECJB.
> **Snippet:**
> ```python
> # before
> shape = ndi.binary_fill_holes(shape)
> # after: label the holes, and close only those below a size
> filled = ndi.binary_fill_holes(mask)
> parts, count = ndi.label(filled & ~mask)
> close = np.asarray(ndi.sum(np.ones_like(parts), parts, index=range(1, count + 1))) < limit
> mask = mask | np.isin(parts, 1 + np.flatnonzero(close))
> ```
> **Check:** Put the cut-out on magenta: magenta shows through the loops, and not through the sole's grooves.
> **Applies to:** other: SciPy ndimage
> **I assumed:** filling holes in the shape only protected dark grooves in the sole.
> **What happened:** the heel's pull loop and a lace loop became solid black, which shows on every wall.
> **The fix:** a limit by size, for a shoe with loops and no mesh. At 1500 px wide, under 900 px closed the grooves and left the loops open. On mesh the same limit punches holes, and L-ECJB replaced it as the default.
> **Evidence:** the `fill_below` setting in `scripts/photos/pipeline.py`.

> **Lesson: Keeping small holes open punches holes through mesh: close every hole, and mark a real one with a seed** (My own mistake; learned by the AI (Claude Opus 5.5))
> **Rule:** If a wall shows through a mesh upper, a small-hole limit (L-9XG6, the earlier attempt) left its cells open. Close every hole by default, and keep a real hole by placing a background seed inside it.
> **Snippet:**
> ```python
> # before: fill_below 60 on every shoe
> close = np.asarray(ndi.sum(np.ones_like(parts), parts, index=range(1, count + 1))) < 60
> # after: close every hole, except those holding a bg seed
> close = np.ones(count, bool)
> marked = np.unique(parts[keep & (parts > 0)])
> close[marked - 1] = False
> ```
> **Check:** On a dark wall and on magenta: no patches through the mesh, and the marked loops are open.
> **Applies to:** other: SciPy ndimage
> **I assumed:** keeping small holes open, the limit from L-9XG6, was the safe default on every shoe, for lace gaps and pull loops.
> **What happened:** on two shoes the mesh cells have the backdrop's grey, and the wall showed through the shoe in patches. A first version of the seed only removed the seed's own pixels, and left a ring of backdrop around it.
> **The fix:** a seed keeps the whole hole it sits in open, whatever its size.
> **Evidence:** `fill_holes` in `scripts/photos/pipeline.py`. A shoe with loops and no mesh may still set `fill_below`.

The two photographs that came already cut out onto pure white needed yet another rule.

> **Lesson: A white sole already on pure white needs two thresholds: a higher line above the sole, a lower one below** (My own mistake; learned by the AI (Claude Opus 5.5))
> **Rule:** If a product on pure white gets a speckled fringe at a low threshold and loses its sole at a high one, skip the graph cut, and use a higher line above the sole and a lower one below.
> **Snippet:**
> ```python
> # before: one threshold, then a graph cut
> labels = np.where(distance > t_low, cv2.GC_PR_FGD, cv2.GC_PR_BGD).astype(np.uint8)
> # after: t_low 4 above the sole, 1.4 below it, no graph cut
> mask = distance > t_low
> line = int((low_rule["y"] - origin[1]) * small)
> mask[line:] = distance[line:] > low_rule["t"]
> ```
> **Check:** At 200% on a dark wall: no speckle around the upper, and the sole whole.
> **Applies to:** other: JPEG sources already on white
> **I assumed:** one threshold could separate the shoe from the white.
> **What happened:** a low one kept the sole, but gave the upper a speckled fringe from JPEG ringing. A high one gave a clean upper and ate the sole's blown-out lugs. A graph cut gave the white lugs to the background.
> **The fix:** two lines, with the blown-out lugs marked by hand, since white on white cannot be keyed, as L-7GKR says.
> **Evidence:** `coral-runner` and `blue-runner` in `scripts/photos/shoes.json`. Six lugs on one of them are hand-marked.

### The edge: closed-form matting

A hard shape is not an edge. Each edge pixel is a blend of the shoe and the backdrop:

`Iᵢ = αᵢFᵢ + (1 − αᵢ)Bᵢ`

That is three equations and seven unknowns per pixel, so it needs an assumption. Closed-form matting (Levin, Lischinski and Weiss, 2008) assumes that within a small window the foreground and background colours each lie on a line. Then alpha is a linear function of colour in that window, `αᵢ ≈ a·Iᵢ + b`, and the window's `a` and `b` can be eliminated. What remains is a quadratic cost `αᵀLα`, where `L` is the matting Laplacian, minimised with the known pixels fixed. That is one sparse linear system, with no training data.

Known pixels come from a trimap: certainly shoe, certainly not, and an unknown band whose width is `band`, 3 or 4 pixels on small sources and up to 9 on large ones:

```python
band = shoe.get("band", 9)
inside = ndi.binary_erosion(solid, iterations=band)
outside = ~ndi.binary_dilation(solid, iterations=band)
# Seeds are certain: the matting may not undo them.
inside |= polygons(shoe.get("fg"), origin, work, (wh, ww))
trimap = np.full((wh, ww), 0.5, np.float64)
trimap[inside] = 1.0
trimap[outside] = 0.0

alpha = pymatting.estimate_alpha_cf(
    image.astype(np.float64) / 255,
    trimap,
    laplacian_kwargs={"epsilon": 1e-6},
)
```

The matting is the slow step, so its result is cached per shoe:

```python
if cache.exists() and not shoe.get("no_cache"):
    return np.asarray(Image.open(cache).convert("L"), np.float32) / 255
```

> **Lesson: A cached alpha mask outlives the picture it was made from: delete it whenever a source picture changes** (My own mistake; learned by the AI (Claude Opus 5.5))
> **Rule:** If a patch of old background stays inside a cut-out after you fixed the source, the cached mask is stale. Delete a picture's mask whenever the picture changes before matting.
> **Snippet:**
> ```python
> # before: any mask on disk wins, even one made from an older version of the picture
> if cache.exists() and not shoe.get("no_cache"):
>     return np.asarray(Image.open(cache).convert("L"), np.float32) / 255
> # after: drop the mask of a changed picture before that check runs
> if source_changed:  # yours to define: this pipeline has no such check, so the file was deleted by hand
>     cache.unlink(missing_ok=True)
> if cache.exists() and not shoe.get("no_cache"):
>     return np.asarray(Image.open(cache).convert("L"), np.float32) / 255
> ```
> **Check:** Run again: the matting runs instead of reading the cache, and the patch has gone.
> **Applies to:** other: PyMatting 1.1.16
> **I assumed:** correcting the source picture corrected the cut-out.
> **What happened:** an edit to one source picture went wrong and pulled backdrop colour into the shoe. I corrected the edit, but the mask was cached from the wrong version, so a salmon patch stayed inside the shoe's outline.
> **The fix:** delete the cached mask whenever the picture it came from changes.
> **Evidence:** the patch left with the mask.

## Clean the edge

Alpha alone leaves the old backdrop's colour in the rim, and that shows as a fringe on the opposite wall. `clean_edge` replaces the colour of the rim's pixels, in three moves:

```python
a = np.clip((np.clip(alpha, 0, 1) - tighten) / (1 - tighten), 0, 1)
foreground = pymatting.estimate_foreground_ml(rgb.astype(np.float64) / 255, a.astype(np.float64)) * 255

interior = ndi.binary_erosion(a > 0.985, iterations=2)
indices = ndi.distance_transform_edt(~interior, return_distances=False, return_indices=True)
inner = rgb[indices[0], indices[1]].astype(np.float32)

out = foreground.astype(np.float32)
rim = a < 0.985
out[rim] = 0.55 * out[rim] + 0.45 * inner[rim]
near_edge = ~interior
capped = np.minimum(out, inner + rim_allow)
out[near_edge] = capped[near_edge]
```

1. **Tighten:** a small cut, 3% by default, removes the faintest alpha, which is mostly noise.
2. **Estimate the foreground:** `estimate_foreground_ml` solves for the pure shoe colour behind each blended pixel.
3. **Lean on the material:** `distance_transform_edt` with `return_indices` finds each pixel's nearest solid interior pixel. The rim takes 45% of that colour, and may be at most `rim_allow` levels brighter than it. A pale rim on a dark shoe is the halo that shows on a dark wall, which is what L-9VVE warns of.

The edge step cannot deal with colour that is inside the shoe.

> **Lesson: Removing a coloured backdrop does not remove its bounced light: neutralise a black and white shoe** (My own mistake; learned by the AI (Claude Opus 5.5))
> **Rule:** If a white shoe stays pink after leaving a coloured backdrop, the light bounced onto it. For a shoe that is itself black and white, keep about 10% of its colour and raise exposure about 12%.
> **Snippet:**
> ```python
> # before: cut out, nothing else, and the white stays salmon
> # after, with "gain": 1.12 and "desaturate": 0.1 in this shoe's settings
> gain = shoe.get("gain", 1.0)
> if gain != 1.0:
>     final_rgb = np.clip(final_rgb.astype(np.float32) * gain, 0, 255).astype(np.uint8)
> keep = shoe.get("desaturate")  # the share of its colour the shoe keeps
> if keep is not None:
>     lab = cv2.cvtColor(final_rgb.astype(np.float32) / 255, cv2.COLOR_RGB2LAB)
>     lab[..., 1:] *= keep
>     final_rgb = (np.clip(cv2.cvtColor(lab, cv2.COLOR_LAB2RGB), 0, 1) * 255).astype(np.uint8)
> ```
> **Check:** On a pale wall the white reads white and not grey, with no orange in the black gloss.
> **Applies to:** other: OpenCV LAB colour
> **I assumed:** taking the backdrop away would take its colour away.
> **What happened:** a court shoe shot on orange came out salmon, with orange reflections in its black gloss.
> **Why:** a white surface shows the light that falls on it, and the exposure step is needed because a desaturated white looks grey on a pale wall.
> **The fix:** this is white balance for one material, so never do it to a coloured shoe.
> **Evidence:** `court-panel` in `scripts/photos/shoes.json`.

## Level the sole

A shoe that stands on a shelf cannot lean. `sole_tilt` finds the lowest point of the mask in two bands of the shoe's length, 10–42% and 62–92%, roughly the heel and the ball of the foot, and takes the angle of the line through them:

```python
for lo, hi in ((0.10, 0.42), (0.62, 0.92)):
    col_a, col_b = int(x0 + lo * w), int(x0 + hi * w)
    bottoms = [(c, np.where(mask[:, c])[0][-1]) for c in range(col_a, col_b) if mask[:, c].any()]
    c, y = max(bottoms, key=lambda p: p[1])
    lows.append((float(c), float(y)))
(xa, ya), (xb, yb) = lows
return float(np.degrees(np.arctan2(yb - ya, xb - xa)))
```

The picture is rotated by that angle. It also turned out to be a good test of everything before it.

> **Lesson: A shadow under the sole breaks levelling too: read the printed angle after every change to a mask** (My own mistake; learned by the AI (Claude Opus 5.5))
> **Rule:** If a shoe shot on a flat floor levels by many degrees, the lowest points are in the shadow and the mask is wrong. Print the angle, and read it after any change to the mask.
> **Snippet:**
> ```python
> # before: the lowest points found were in the floor shadow: levelled by 10.6°
> print(f"  levelled by {tilt:+.2f} deg (plus {rough:+.2f} by hand)", flush=True)
> # after: for a pale sole on a dark floor, anything dark below the line is shadow: 3.6°
> gate = np.clip((lightness - rule["L"]) / 12, 0, 1)
> alpha[line:] *= gate[line:]
> ```
> **Check:** Read the printed angle: a few degrees on a flat-floor shot.
> **Applies to:** other: mask-based image levelling
> **I assumed:** a floor shadow was only a problem for the edge.
> **What happened:** a mesh runner levelled by 10.6°, because the lowest points found were in the floor shadow. With the shadow gone it needed 3.6°.
> **The fix:** take the shadow out of the mask with a brightness gate below the sole's line, and treat a large angle as a sign of a wrong mask.
> **Evidence:** the `dark_below` setting in `scripts/photos/pipeline.py`.

A floor shadow can also be as dark as the lugs beside it, and then neither a rule nor a gate can tell them apart. Those were traced by hand, as `erase` polygons in the finished picture's pixels, with a slight blur on the edge of the cut. On four generated shoes the lug line or the shadow at the ends is hand-traced, and at full size the lug tips on one of them look a little cut.

## Normalise, export, measure

After levelling, each shoe is cropped tight, scaled so that its sole is 2000 px long, and given a margin of 4% of the sole's length. It is never scaled up, because nothing here invents detail:

```python
target = MASTER_SOLE_PX * shoe.get("scale", 1.0)
factor = min(target / sole_len, 1.0)  # never above the source: no invented detail
```

The canvas follows the full sole length, even for a shoe with a `scale` below 1, so all pictures keep the same proportions and draw at one width with matching soles.

The export writes WebP at 480, 960 and 2000 px, never wider than the master, within 70 KB at 960 and 280 KB at 2000. It also writes AVIF, kept when its decode costs at most twice WebP's. It measured 0.68× with Pillow, but not on its own in Chrome. A low-quality placeholder under 400 bytes goes in as a data address.

The last step is the one the wall depends on. The sole line and contact range are measured on the finished master:

```python
alpha = np.asarray(master.getchannel("A")) > 128
bottom = int(np.where(alpha.any(axis=1))[0][-1])
# A lugged sole only touches at its tips, so its shadow reads from a deeper band.
band = alpha[max(0, bottom - round(master.width * shoe.get("contact_band", 0.012))) : bottom + 1].any(axis=0)
cols = np.where(band)[0]
m["contact"] = [round(float(cols[0] / master.width), 4), round(float((cols[-1] + 1) / master.width), 4)]
m["baseline"] = round((bottom + 1) / master.height, 4)
```

`bottom + 1` matters: the baseline is the line under the last row of the sole, not through it. The generated `photos.ts` then holds these numbers along with `hue` for the colour sort, and `lightness` for the next problem.

## Standing on a dark wall

One shoe is black, the wall in the dark theme is nearly black too, and the requirement was an outline standing at least 1.6:1 off the wall.

`components/shoe-wall/styles.css`, lines 629–633:

```css
/* A black shoe in a dark room: its outline must stand 1.6:1 off the wall. */
.pw-root[data-theme="dark"] [data-tone="dark"] .pw-img,
:where(.dark) .pw-root[data-theme="auto"] [data-tone="dark"] .pw-img {
  filter: brightness(1.32);
}
```

`components/shoe-wall/Slot.tsx`, lines 106–106:

```tsx
      data-tone={shoe.lightness !== undefined && shoe.lightness < 0.2 ? "dark" : undefined}
```

> **Lesson: More light behind a black shoe makes it worse on a dark wall: measure which side is brighter first** (My own mistake; learned by the AI (Claude Opus 5.5))
> **Rule:** If a black product vanishes on a dark wall, measure its edge against the wall before adding light. If the edge is already the brighter side, a backlight closes the gap: brighten the product instead.
> **Snippet:**
> ```css
> /* before: a stronger downlight behind the shoe */
> /* after: a fainter downlight, and the shoe itself a little brighter */
> .pw-root[data-theme="dark"] [data-tone="dark"] .pw-img { filter: brightness(1.32); }
> ```
> **Check:** On a real screenshot, compare relative luminance just outside and just inside the outline: 1.75:1, above the 1.6 needed.
> **Applies to:** other: CSS filter and luminance contrast on dark themes
> **I assumed:** a brighter pool behind a black shoe separates it from a dark wall.
> **What happened:** measured on a screenshot, the shoe's edge pixels were brighter than the wall behind them, because leather has highlights: outside 0.0144, inside 0.0325, a contrast of 1.28:1. Light behind it brings the two closer until they match. To win by light alone the backdrop would need about 0.08 luminance, which is a glowing dome.
> **The fix:** a static `brightness(1.32)` on shoes with lightness under 0.2, in the dark theme only, and a fainter downlight. It looks better lit rather than lit from behind.
> **Evidence:** 1.28:1 before and 1.75:1 after, measured on a real screenshot of the production build.

## What is not perfect

- Edges were inspected by eye, at 100% of the master and at about 250% of a medium slot, on mid-grey, the dark wall and magenta. No halo was measured with numbers.
- Two shoes come from 1280 px previews, seven from 1168 px images and three from 1536 px ones. Only five shoes have a 2000 px export, and views exist only on those.
- The tips of the lugs on one shoe and the back of the heel on another are a little less clean at full size than the rest.
- The terms of the two image tools for shipping their images in an MIT product are **not verified.** Check them yourself, or replace the pictures.

## Make it yours

Install the component and give it your own pictures:

```bash
npx shadcn@latest add https://upfork.dev/r/shoe-store-product-grid-react.json
```

To make cut-outs of your own, take `scripts/photos` from the [public source](https://github.com/upfork-dev/free-components/tree/main/pacewall). It needs Pillow, NumPy, SciPy, OpenCV and PyMatting. Source pictures do not ship with it, so add yours and describe each in `shoes.json`: its source file, its crop, and whichever of the settings above it needs. For a new shoe, start from `plain` on a plain backdrop, tune with `PW_SHAPE_ONLY=1`, read the printed angle, and look at the result on a dark wall and on magenta before you trust it.

---

On UpFork: https://upfork.dev/articles/cut-shoes-out-of-photos-so-they-stand-exactly-on-a-shelf-a-background-remova
