Open in een willekeurige Shopify-catalogus een product en kijk naar de alt-tekst van de foto's. Meestal is hij leeg, of staat op elke foto dezelfde producttitel. Bij een paar duizend producten met elk vijf of zes foto's gaat het al snel om tienduizenden afbeeldingen, en die schrijft niemand met de hand.
Wij hebben daarvoor een aanpak gebouwd waarbij geen enkele foto door een taalmodel wordt beschreven. We delen de foto's in op de hoek waaruit ze gemaakt zijn, en bouwen de tekst uit productgegevens die al kloppen. Hieronder hoe het werkt, met code die we zelf hebben gebouwd en gedraaid.
De vraag omdraaien
Een goede alt-tekst voor een productfoto bestaat uit twee delen: wat het product is en wat deze foto ervan laat zien. Het eerste deel staat al in je catalogus. Merk, model en kleur zijn velden in Shopify, en die kloppen, want daar verkoop je op.
Alleen het tweede deel verschilt per foto. En productfotografie is vrijwel altijd gestandaardiseerd: elke horloge wordt van voren, van opzij en van achteren gefotografeerd, op dezelfde achtergrond. Dan is de vraag niet meer "beschrijf deze foto", maar "in welke van deze drie groepen valt hij". Dat is een indeling, en die kun je per groep controleren.
| Elke foto laten beschrijven | Indelen op hoek | |
|---|---|---|
| Uitkomst per foto | Vrije tekst | Een van een paar vaste labels |
| Controle | Per tekst, bij tienduizenden foto's niet te doen | Steekproef per label, plus de twijfelgevallen |
| Productfeiten | Door het model afgeleid uit de foto | Uit de catalogus |
| Consistentie | Elke tekst anders geformuleerd | Overal dezelfde opbouw |
| Kosten | Per foto een betaalde aanroep | Lokaal, geen API |
Hoe CLIP hier past
CLIP is een beeldmodel dat van elke afbeelding een vector maakt, een reeks getallen die beschrijft wat erop staat. Foto's die op elkaar lijken, krijgen vectoren die dicht bij elkaar liggen. Je hoeft het model niet te trainen. Je geeft per hoek een paar voorbeeldfoto's, neemt het gemiddelde van hun vectoren als anker, en vergelijkt elke productfoto met die ankers. Het anker met de hoogste gelijkenis wint.
Het basismodel openai/clip-vit-base-patch32 draait op een gewone laptop zonder GPU. Je hebt er geen aparte infrastructuur voor nodig.
De indeling in code
Zet per hoek een paar voorbeeldfoto's in een eigen map, bijvoorbeeld referenties/wijzerplaat/, referenties/zijkant/ en referenties/achterkant/, en zet de productfoto's in fotos/. Het script schrijft per foto de hoek en de gelijkenis weg als CSV.
# pip install torch transformers pillow
import csv
import sys
from pathlib import Path
import torch
from PIL import Image
from transformers import CLIPModel, CLIPProcessor
MODEL = "openai/clip-vit-base-patch32"
GRENS = 0.80 # onder deze gelijkenis gaat de foto naar een mens
model = CLIPModel.from_pretrained(MODEL).eval()
processor = CLIPProcessor.from_pretrained(MODEL)
@torch.no_grad()
def embed(paden):
beelden = [Image.open(p).convert("RGB") for p in paden]
invoer = processor(images=beelden, return_tensors="pt")
uit = model.get_image_features(**invoer)
# transformers 4 geeft een tensor terug, versie 5 een object met pooler_output
vectoren = uit if torch.is_tensor(uit) else uit.pooler_output
return vectoren / vectoren.norm(dim=-1, keepdim=True)
def ankers(map_referenties):
"""Eén gemiddelde vector per hoek, uit referenties/<hoek>/*.jpg."""
namen, vectoren = [], []
for hoek in sorted(p for p in Path(map_referenties).iterdir() if p.is_dir()):
v = embed(sorted(hoek.glob("*.jpg"))).mean(dim=0)
namen.append(hoek.name)
vectoren.append(v / v.norm())
return namen, torch.stack(vectoren)
def deel_in(map_fotos, namen, anker, batch=32):
fotos = sorted(Path(map_fotos).glob("*.jpg"))
for i in range(0, len(fotos), batch):
deel = fotos[i : i + batch]
gelijkenis = embed(deel) @ anker.T
score, beste = gelijkenis.max(dim=1)
for pad, s, b in zip(deel, score.tolist(), beste.tolist()):
yield pad.name, namen[b] if s >= GRENS else "nakijken", round(s, 3)
if __name__ == "__main__":
namen, anker = ankers(sys.argv[1])
schrijver = csv.writer(sys.stdout)
schrijver.writerow(["foto", "hoek", "gelijkenis"])
schrijver.writerows(deel_in(sys.argv[2], namen, anker))
Draaien gaat met python hoek.py referenties fotos > hoeken.csv. We hebben het getest met transformers 5.20 en torch 2.14. Let op die regel met pooler_output: in transformers 5 geeft get_image_features geen kale tensor meer terug, en de voorbeelden die je online vindt breken daarop.
Onze test was klein en synthetisch: drie getekende hoeken met elk drie referenties en vier testbeelden, plus één beeld met alleen ruis. Alle twaalf testbeelden kwamen in de goede groep met een gelijkenis tussen 0,96 en 1,0. Het ruisbeeld scoorde 0,71 en ging naar "nakijken". Dat zegt dat de code doet wat hij moet doen. Het zegt niets over de nauwkeurigheid op jouw foto's, daarvoor is de steekproef verderop.
Van hoek naar alt-tekst
Met de hoek per foto is de tekst een kwestie van invullen. Wij gebruiken het patroon merk, model, kleur, hoek. Houd de labels in de tekst leesbaar, de mapnamen zijn voor de code.
LABEL = {
"wijzerplaat": "wijzerplaat",
"zijkant": "zijkant",
"achterkant": "achterkant van de kast",
}
def alt_tekst(product, hoek):
return f"{product['merk']} {product['model']} {product['kleur']}, {LABEL[hoek]}"
Dat geeft voor één horloge drie verschillende teksten, zoals "Nordvik Classic blauw, wijzerplaat" en "Nordvik Classic blauw, zijkant", in plaats van drie keer de titel. Het merk is verzonnen, het patroon niet. Is een tekst fout, dan zit de fout in een veld van je catalogus of in de hoek, en beide kun je terugvinden.
Terugschrijven naar Shopify
Productfoto's zijn in Shopify MediaImage-objecten. De ID's haal je op via product.media, en de alt-tekst zet je met de mutatie fileUpdate, die meerdere bestanden per aanroep accepteert:
mutation AltTekst($files: [FileUpdateInput!]!) {
fileUpdate(files: $files) {
files { id alt }
userErrors { field message code }
}
}
Met als variabelen een lijst van { "id": "gid://shopify/MediaImage/...", "alt": "..." }. Een bestand moet de status READY hebben om bijgewerkt te kunnen worden, en een ID die niet bestaat komt terug als FILE_DOES_NOT_EXIST in userErrors. Lees na het schrijven een deel van de producten terug, een mutatie die slaagt is nog geen bewijs dat de juiste tekst op de juiste foto staat.
Waar je op let
- De grens is geen vaste waarde. De 0,80 in het script werkte voor onze testbeelden. Draai op je eigen catalogus eerst een paar honderd foto's, kijk bij welke score de fouten beginnen, en zet de grens daar net boven.
- Sfeerfoto's vallen buiten de indeling. Een horloge om een pols of een foto van de verpakking lijkt op geen enkel anker. Je kunt er een eigen groep voor maken, of ze naar "nakijken" laten gaan.
- Controleer per groep, niet over alles. Neem per hoek een steekproef. Een fout die structureel is, bijvoorbeeld zijkant en achterkant die door elkaar lopen, zie je anders niet.
- Kies je referenties breed. Neem voorbeelden uit verschillende kleuren en modellen. Drie zwarte horloges als anker voor de wijzerplaat trekken lichte wijzerplaten naar een andere groep.
- Houd de tekst feitelijk. Alt-tekst is voor schermlezers en voor zoekmachines die willen weten wat er op de foto staat. Zoekwoorden stapelen helpt geen van beide.
Waarom dit verder gaat dan alt-tekst
Het nuttigste aan dit voorbeeld is niet het model, maar de omzetting. Veel taken die op contentproductie lijken, zijn eigenlijk een indeling met een sjabloon erachter: producttitels voor een feed, categorieën voor een assortiment, het type pagina in een sitemap. Zodra het variabele deel uit een kleine, vaste set komt, kun je het controleren, en dan hoef je de uitkomst niet meer te geloven maar kun je hem meten.
Hetzelfde principe zit achter een Magento-catalogus naar Google Shopping met Vertex AI: het model doet één afgebakende stap, de structuur eromheen bepaalt de uitkomst.