PostsAPI Docs/ BlogPricingSuppliersGet Started - Free

Choosing the Right Image: A Practical Guide to PromoStandards Media Content

A client asked us a question last week that sounds like it should have a one-line answer: for this color variant, which image do I show?

It doesn’t have a one-line answer. Media Content is one of the PromoStandards services where suppliers, all of them technically compliant, model the same thing in genuinely different ways. If you write your image picker against one supplier’s data, it will break on the next one. Not with an error, which would at least be honest, but by silently showing a navy shirt on the red variant.

This is the logic we actually run in production, why each piece of it exists, and where the code lives.

The root of the problem: what is an image attached to?

A MediaContent record carries both a productId and an optional partId, plus a color field. Which of those a supplier populates is where the divergence starts.

Some suppliers key images by color. SanMar and S&S Activewear send many images per color, and a garment typically has a part for every size. If they attached media to every partId, they would have to repeat the same six photos for XS, S, M, L, XL, and 2XL, which is the same image set six times over for no gain. So they lean on the color field instead.

Some suppliers key images by part. HIT gives you images tied to a partId and leaves color blank. Color matching against their data returns nothing, because there is nothing to match against.

And some only attach images to the product, with partId null entirely. These are your group shots and collages: the whole product family in one frame, not any particular variant.

None of this is a violation of the standard. The standard permits all three. Your code has to survive all three.

The fallback chain

Because no single key works everywhere, you cascade from most specific to least:

  1. partId. The most precise match available. If a supplier tells you this exact image belongs to this exact part, believe them.
  2. The color field. If no image carries that part, match on color instead.
  3. The other color field. A part exposes both colorName and standardColorName. Suppliers are inconsistent about which one lines up with the color on the media record, so if your primary choice misses, try the other one.

In our Shopify sync app, that cascade is literally this:

base = get_primary_base_for_part(part_id) # 1. partId if not base: color = product.get_variant_color(variant, self.color_field) base = get_primary_base_for_color(color) # 2. colorName if not base: color = product.get_variant_color(variant, self.fallback_color_field) base = get_primary_base_for_color(color) # 3. standardColorName

We default to colorName with standardColorName as the fallback, but it’s a per-supplier setting, because for some the standardized name is the one that matches.

Two details that took us real production incidents to learn:

Normalize before you compare colors. Supplier color strings are not clean. "Royal / White", "ROYAL-WHITE", and "royal white" all mean the same thing. We strip spaces, hyphens, slashes, and #, then lowercase, before comparing. A naive .lower() comparison misses matches that are plainly the same color to a human.

For HIT, skip the color steps entirely. Their color field is blank, so the color lookups can only produce noise. We register a part-only strategy for them and stop after step 1. It’s worth having a per-supplier hook here rather than pretending one cascade fits everyone.

Filtering is only half the job. You still have to rank.

Suppose the cascade hands you eleven images for Royal. Which one is the image?

We sort with a comparator that applies four rules in strict priority order:

  1. Single-part images beat group shots. A singlePart image shows one product. A group shot shows five, and it makes a terrible variant thumbnail.
  2. Primary (or blank) beats everything else. Which of these you want depends on the job, so it’s a setting. If you’re building a catalog, the primary shot is the marketing image the supplier chose. If you’re building a decoration preview, you want the blank shot, an undecorated product you can render artwork onto. Shipping a decorated sample as your blank canvas is a bad day.
  3. Front view beats other angles. A rear view is a fine second image and a poor first one.
  4. Closest to the target height wins. Not the biggest. The closest.

That last rule surprises people, so it’s worth dwelling on. We don’t sort descending by resolution, because “biggest” is rarely what you want. We pick a target height for the context and choose the image nearest to it:

ContextTarget height
Thumbnail50px
Small470px
Base (the default)1100px
Best1900px

A 4000px hero is not a better thumbnail than a 50px one. It’s a slower one.

There’s also a hard ceiling on what we hand back. Anything over 25MB or wider than 2000px gets routed through a resize service rather than served raw, because a 40MB TIFF is not something you want your storefront pulling on every page view.

Throwing out what should never be shown

Before any of the above runs, the pool gets cleaned:

The SanMar trap, and why the obvious fix breaks HIT

Here is the most useful thing in this post.

SanMar sends the same low-resolution primary image for every color. It is 300x450, and it’s wrong for most of the colors it’s attached to. They also send a good, high-resolution model shot per color. If your picker prefers the “primary” class type, it will confidently choose the wrong image for nearly every variant.

The fix looks obvious: set a minimum resolution and let the bad primary fall out. That’s exactly what we do, with a floor of 800px of height by default. The 300x450 primary is below the floor, never enters the pool, and the good per-color shot wins without any supplier-specific hack.

Then we shipped that, and HIT products started importing with zero images.

HIT publishes no dimensions at all. height is null. Our filter had been treating unknown as zero, so every HIT image tested as “below the minimum” and got discarded. The product imported clean, with no media, and no error.

The correct rule is to fail open on unknown:

mc.is_high_res or mc.height is None or mc.height >= self.minimum_resolution

Drop an image only when it explicitly reports a height below the floor. When the supplier didn’t tell you how big it is, keep it and let the later ranking rules sort it out. Absence of data is not evidence of a small image.

If you take one thing from this post, take that shape: a filter that treats missing data as a failing value will quietly delete an entire supplier’s catalog, and it will do it without raising anything you’d notice.

What we don’t do yet

We do not detect that a supplier is reusing one image across every color. Nothing counts how many colors share a URL and infers “this is probably generic.” If SanMar’s shared primary happened to be 1200px tall, it would still land on all forty variants today, and the resolution filter wouldn’t save us.

Dedup is exact-URL only, and only within a scope: we won’t list the same URL twice in one gallery, but we have no perceptual hashing and no cross-color heuristics. That’s a real gap, and it’s the direction we’d extend this next.

The code

All of this lives in psdomain , our open-source Python model for PromoStandards. The image logic specifically is in services/media_content.py, with the class-type constants and the comparators in model/media_content.py.

It’s Python because that’s what we write, but there’s nothing Python-specific about the approach. The cascade, the four-rule ranking, and the fail-open filter port to any language in an afternoon.

To pull the media in the first place, see the Media Content guide  in the docs. If you’re wiring this up and hitting a supplier that behaves in a way this post doesn’t cover, tell us . That’s usually how we find out about the next quirk.