- Intro
- Prompt 0 - Startpunt en proof of concept
- Prompt 1 - Zekerheidsinschatting
- Prompt 2 - JSON output
- Wat doet LLM met Vision QWEN3 met deze foto en prompt?
- Prompt 3 - Dubbele benoeming voorkomen
- Probleem - Vision model doet "resizing"
- Prompt 4 - Source dimensions and scaling
- Constatering - LLM met vision schalen de input foto
- Prompt - Strakkere bbox
- Script - Aanpassingen
- Oplossing - Het kwartje gaat vallen!
- Oplossing - Genormaliseerde waarden
- Prompt 6 - Genormaliseerde waarden
- Prompt 7 - Geen attribuut labels, beperk grote boxes
- Oplossing - Gebruik de juiste LLM met Vision voor de job!
- Bijna perfectie! - GLM4.6V-flash
- Prompt 8 - Dwing een codeblock af
- Ervaring en eindconclusie

Intro #
Wellicht ken je ze wel, die foto’s of video’s met kaders om personen of objecten heen om ze te beschrijven en te herkennen, een veelgebruikte engine daarvoor is YOLO van Ultralytics:
Website: https://www.ultralytics.com/yolo
Github: https://github.com/ultralytics/ultralytics
Maar ik had een gedachtenexperiment:
Een LLM met vision engine kan objecten/gebeurtenissen van een plaatje/foto omschrijven (helaas nog geen video ;-)), is het ook mogelijk om daar bounding boxes omheen te zetten zoals YOLO (OpenCV)?
En zo ben ik begonnen aan de weg naar bouding boxes met een Vision capable LLM.
Prompt 0 – Startpunt en proof of concept #
Ik gebruik het model “ministral-3-14b-instruct-2512” GUFF in LM studio, met een prompt als:
Kan je mij de bounding boxes van de personen geven op deze foto? per box een start linksboven x,y en een einde rechtsonder x,y

Note: De foto is hier pixelized/onherkenbaar gemaakt om evt copyright strikes te voorkomen.
Ik dacht: “dat kan iets beter!” met de volgende aangepaste prompt:
Kolom 1: Het object wat je herkent op de foto
Kolom 2: Een coördinaat in pixels van de boundingbox linksboven van het object
Kolom 3: Een coördinaat in pixels van de boundingbox rechtsonder van het object
Aandachtpunten
de pixels of coordinaten van de boudingboxes masg een kleine marge hebben
het allerkleinste detail hoef je niet te benoemen, benoem bijvoobeeld maximaal 10 objecten, dieren of personen
Geeft als resultaat:

Note: De foto is hier pixelized/onherkenbaar gemaakt om evt copyright strikes te voorkomen.
Ander voobeeld:

Note: De foto is hier pixelized/onherkenbaar gemaakt om evt copyright strikes te voorkomen.
Dat is al een mooie tabelvorm met object omschrijving en (pixel) coördinaten!
Prompt 1 – Zekerheidsinschatting #
Ik heb nu een foto genomen die vrij gebruikt kan worden (copyright free):
https://pixabay.com/nl/photos/mensen-leven-musicus-muziek-7707981/
Ik weet dat andere visual software zoals YOLO ook een inschatting kan geven over “hoe zeker het model is over het object dat hij herkent”, dus dat ging ik eens proberen met de volgende aanpassing op de prompt:
Geef in tabelvorm het volgende weer:
Kolom 1: Het object wat je herkent op de foto
Kolom 2: De inschatting (of zekerheid) in % (procent) dat je denk wat het object is wat je hebt omschreven.
Kolom 3: Een coördinaat in pixels van de boundingbox linksboven van het object
Kolom 4: Een coördinaat in pixels van de boundingbox rechtsonder van het object
Aandachtpunten
de pixels of coordinaten van de boudingboxes masg een kleine marge hebben
het allerkleinste detail hoef je niet te benoemen, benoem bijvoobeeld maximaal 10 objecten, dieren of personen
Resultaat:

Prompt 2 – JSON output #
Omdat ik graag een MCP tool wil bouwen hierop, heb ik samen met ChatGPT een voorbeeld script en prompt gebouwd.
Het idee is: Foto > LLM geeft JSON terug (en roept een python tool aan voor het creeren van de bounding boxes) > Foto met bounding boxes.
ChatGPT:
Gebruik dit als vervanging van je huidige prompt:
- Geef output als geldig JSON.
- Max 10 detecties.
- confidence als 0–1 float (of % als je wilt, maar float is makkelijker)
- bbox altijd integers
- geen extra tekst eromheen
Mini-tip (kwaliteit)
Mistral/VLM bounding boxes zijn vaak “best effort”. Als je wil dat de boxes consistent zijn:
- vraag om éérst grof (persoon, kinderwagen, accordeon)
- en beperk kleine objecten (flesje, voet) tenzij ze echt prominent zijn
Dat scheelt ruis in je teken-tool.
De python tool om bouding boxes te plaatsen in een JPG bestand: draw_bboxes.py
Note: benodigde bibliotheek: pillow (in je venv omgeving: pip3 install pillow)
import json
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
def clamp(v, lo, hi):
return max(lo, min(hi, v))
def main():
if len(sys.argv) < 3:
print("Usage: python draw_bboxes.py input.jpg output.jpg [detections.json]")
print("If detections.json is omitted, JSON is read from stdin.")
sys.exit(1)
in_path = Path(sys.argv[1])
out_path = Path(sys.argv[2])
if len(sys.argv) >= 4:
det_path = Path(sys.argv[3])
data = json.loads(det_path.read_text(encoding="utf-8"))
else:
data = json.loads(sys.stdin.read())
img = Image.open(in_path).convert("RGB")
W, H = img.size
draw = ImageDraw.Draw(img)
# Probeer een font te laden; valt terug op default als het niet lukt
try:
font = ImageFont.truetype("arial.ttf", 16)
except Exception:
font = ImageFont.load_default()
detections = data.get("detections", [])
for det in detections:
label = str(det.get("label", ""))
conf = det.get("confidence", None)
bbox = det.get("bbox", {}) or {}
x1 = int(bbox.get("x1", 0))
y1 = int(bbox.get("y1", 0))
x2 = int(bbox.get("x2", 0))
y2 = int(bbox.get("y2", 0))
# Clamp binnen image bounds
x1 = clamp(x1, 0, W - 1)
y1 = clamp(y1, 0, H - 1)
x2 = clamp(x2, 0, W - 1)
y2 = clamp(y2, 0, H - 1)
# Normaliseer als ze per ongeluk omgedraaid zijn
if x2 < x1: x1, x2 = x2, x1
if y2 < y1: y1, y2 = y2, y1
# Tekst
if isinstance(conf, (int, float)):
txt = f"{label} {conf:.2f}"
else:
txt = label
# Rectangle dikte
thickness = 3
for t in range(thickness):
draw.rectangle([x1 - t, y1 - t, x2 + t, y2 + t])
# Label background (simpel)
tw, th = draw.textbbox((0, 0), txt, font=font)[2:]
pad = 2
bx1 = x1
by1 = max(0, y1 - (th + pad * 2))
bx2 = clamp(x1 + tw + pad * 2, 0, W)
by2 = clamp(by1 + th + pad * 2, 0, H)
draw.rectangle([bx1, by1, bx2, by2], fill=None) # outline only
draw.text((bx1 + pad, by1 + pad), txt, font=font)
img.save(out_path, quality=95)
print(f"Saved: {out_path}")
if __name__ == "__main__":
main()
Prompt:
Geef alleen geldig JSON terug met maximaal 10 objecten.
Het format van de JSON schema moet zijn:
{ "detections":[ { "label":labelstring, "confidence":confidencefloat, "bbox":{"x1":int,"y1":int,"x2":int,"y2":int} } ] }
De variabelen zijn:
labelstring = Het object wat je herkent op de foto
confidencefloat = De inschatting (of zekerheid) in 0–1 float waarde dat je denk wat het object is wat je hebt omschreven.
x1 en y1 = Een coördinaat in pixels van de boundingbox linksboven van het object in integer waarde.
x2 en y2 = Een coördinaat in pixels van de boundingbox rechtsonder van het object in integer waarde.
Aandachtspunten:
1. De pixels of coördinaten van de boudingboxes mag een kleine marge hebben.
2. Benoem de objecten van groot naar klein.
3. Benoem bijvoorbeeld maximaal 10 objecten, dieren of personen.
4. Het allerkleinste detail hoef je niet te benoemen.
Resultaat:

Sla de output op als detections.json:
{
"detections": [
{
"label": "person",
"confidence": 0.98,
"bbox": {"x1": 350, "y1": 100, "x2": 600, "y2": 750}
},
{
"label": "baby_in_stroller",
"confidence": 0.97,
"bbox": {"x1": 180, "y1": 200, "x2": 340, "y2": 650}
},
{
"label": "accordion",
"confidence": 0.99,
"bbox": {"x1": 470, "y1": 300, "x2": 580, "y2": 550}
},
{
"label": "stroller",
"confidence": 0.96,
"bbox": {"x1": 150, "y1": 170, "x2": 380, "y2": 700}
},
{
"label": "baby_bottle",
"confidence": 0.94,
"bbox": {"x1": 250, "y1": 350, "x2": 280, "y2": 400}
},
{
"label": "box",
"confidence": 0.96,
"bbox": {"x1": 400, "y1": 700, "x2": 530, "y2": 780}
},
{
"label": "shoe_1",
"confidence": 0.92,
"bbox": {"x1": 460, "y1": 700, "x2": 500, "y2": 730}
},
{
"label": "shoe_2",
"confidence": 0.92,
"bbox": {"x1": 480, "y1": 680, "x2": 520, "y2": 730}
},
{
"label": "signboard",
"confidence": 0.95,
"bbox": {"x1": 450, "y1": 50, "x2": 580, "y2": 160}
}
]
}
Na het uitvoeren van het python script:
python draw_bboxes.py input.jpg output.jpg detections.json
Resultaat:

Er is enig resultaat, de locatie en grootte van bouding boxes kloppen niet, maar je ziet wel dat er “richting” in zit, het lijkt alleen verschoven.
Wat doet LLM met Vision QWEN3 met deze foto en prompt? #
Ik dacht ik ga eens qwen3-vl-8b gebruiken en kijken wat deze LLM er van maakt:
detections.json
{
"detections": [
{
"label": "woman",
"confidence": 0.98,
"bbox": {
"x1": 455,
"y1": 488,
"x2": 875,
"y2": 905
}
},
{
"label": "baby",
"confidence": 0.97,
"bbox": {
"x1": 242,
"y1": 602,
"x2": 420,
"y2": 748
}
},
{
"label": "accordion",
"confidence": 0.96,
"bbox": {
"x1": 548,
"y1": 602,
"x2": 868,
"y2": 767
}
},
{
"label": "stroller",
"confidence": 0.95,
"bbox": {
"x1": 145,
"y1": 530,
"x2": 540,
"y2": 910
}
},
{
"label": "bottle",
"confidence": 0.94,
"bbox": {
"x1": 320,
"y1": 637,
"x2": 388,
"y2": 675
}
},
{
"label": "building",
"confidence": 0.93,
"bbox": {
"x1": 0,
"y1": 0,
"x2": 999,
"y2": 880
}
},
{
"label": "plastic box",
"confidence": 0.92,
"bbox": {
"x1": 438,
"y1": 901,
"x2": 642,
"y2": 960
}
},
{
"label": "shoe_1",
"confidence": 0.89,
"bbox": {
"x1": 700,
"y1": 872,
"x2": 775,
"y2": 905
}
},
{
"label": "shoe_2",
"confidence": 0.87,
"bbox": {
"x1": 520,
"y1": 880,
"x2": 575,
"y2": 905
}
},
{
"label": "sign",
"confidence": 0.85,
"bbox": {
"x1": 580,
"y1": 60,
"x2": 740,
"y2": 240
}
}
]
}
Zoals je ziet gebruikt QWEN3 niet een mooie/pretty JSON format
Het resultaat van QWEN3:

Mijn gedachte: QWEN3 is niet veel beter dan Ministral3!
Prompt 3 – Dubbele benoeming voorkomen #
ChatPGT kwam nog met de opmerking:
Omdat je nu “shoe” twee keer en “wheel” twee keer krijgt: als je liever samengevoegde labels wilt, voeg toe:
- “Gebruik unieke labels waar mogelijk; als hetzelfde label meerdere keren voorkomt, voeg een suffix toe zoals
shoe_1,shoe_2.”
De bijgewerkte prompt wordt:
Geef alleen geldig JSON terug met maximaal 10 objecten.
Het format van de JSON schema moet zijn:
{ "detections":[ { "label":labelstring, "confidence":confidencefloat, "bbox":{"x1":int,"y1":int,"x2":int,"y2":int} } ] }
De variabelen zijn:
labelstring = Het object wat je herkent op de foto
confidencefloat = De inschatting (of zekerheid) in 0–1 float waarde dat je denk wat het object is wat je hebt omschreven.
x1 en y1 = Een coördinaat in pixels van de boundingbox linksboven van het object in integer waarde.
x2 en y2 = Een coördinaat in pixels van de boundingbox rechtsonder van het object in integer waarde.
Aandachtspunten:
1. De pixels of coördinaten van de boudingboxes mag een kleine marge hebben.
2. Benoem de objecten van groot naar klein.
3. Benoem maximaal 10 objecten, dieren of personen.
4. Het allerkleinste detail hoef je niet te benoemen.
5. Gebruik unieke labels waar mogelijk, als hetzelfde object meerdere keren voorkomt, voeg een suffix toe zoals: shoe_1, shoe_2.
Probleem – Vision model doet “resizing” #
Het probleem van de foto hierboven voorgelegd aan ChatGPT, zijn opmerking:
Je script werkt waarschijnlijk prima — dit ziet er heel sterk uit alsof Mistral de boundingbox-pixels heeft gerapporteerd in een ander beeldformaat dan jouw originele JPG (door resizing in de vision pipeline van LM Studio / mmproj).
Waarom ik dat denk
Jouw afbeelding is 853×1280 (breed×hoog).
Maar in de JSON zitten Y-waarden maar tot ~540. Dat betekent: de boxen zitten “allemaal bovenin”, terwijl de persoon/baby onderin hoort.
Dat gebeurt typisch als het model intern iets als 768×576 (of iets in die buurt) gebruikt en daarop de boxen schat.
Als jij die coords 1:1 tekent op 853×1280, dan eindigt alles te hoog.
In jouw geval lijkt het heel plausibel dat:
- X ongeveer op een canvas van ~768 zit (max X ~680)
- Y ongeveer op een canvas van ~576 zit (max Y ~540)
Dan moet je schalen:
- x_scale ≈ 853 / 768 ≈ 1.11
- y_scale ≈ 1280 / 576 ≈ 2.22
En dan vallen de boxen ineens wél op de juiste plek.
Oplossing 1 (meest clean): laat Mistral “source_size” meegeven
Voeg 1 regel toe aan je prompt:
Voeg ook
"source_size": {"w": int, "h": int}toe met de resolutie waarop je de pixels baseert.
Oplossing 2 (snel fixen): schaal in je Python script met opgegeven model-size
Pas je script aan zodat je (optioneel) de “assumed/model” resolutie meegeeft:
Prompt 4 – Source dimensions and scaling #
Met de tips van ChatGPT om de source size ook mee te geven, zodat we de coordinaten van de bouding boxes te kunnen schalen, heb ik de prompt bijgewerkt:
Geef alleen geldig JSON terug met maximaal 10 objecten.
Het format van de JSON schema moet zijn:
{
"source_size": { "src_w": int, "src_h": int },
"detections": [
{
"label": labelstring,
"confidence": confidencefloat,
"bbox": { "x1": int, "y1": int, "x2": int, "y2": int }
}
]
}
De variabelen zijn:
source_size = de resolutie src_w=width, src_h=height waarop je de bounding box pixels baseert in een integer waarde.
labelstring = Het object wat je herkent op de foto
confidencefloat = De inschatting (of zekerheid) in 0–1 float waarde dat je denk wat het object is wat je hebt omschreven.
x1 en y1 = Een coördinaat in pixels van de boundingbox linksboven van het object in een integer waarde.
x2 en y2 = Een coördinaat in pixels van de boundingbox rechtsonder van het object in een integer waarde.
Aandachtspunten:
1. De pixels of coördinaten van de boudingboxes mag een kleine marge hebben.
2. Benoem de objecten van groot naar klein.
3. Benoem maximaal 10 objecten, dieren of personen.
4. Het allerkleinste detail hoef je niet te benoemen.
5. Gebruik unieke labels waar mogelijk, als hetzelfde object meerdere keren voorkomt, voeg een suffix toe zoals: shoe_1, shoe_2.
De output is:
{
"source_size": {
"src_w": 3840,
"src_h": 5760
},
"detections": [
{
"label": "person",
"confidence": 0.98,
"bbox": {
"x1": 2400,
"y1": 3200,
"x2": 3600,
"y2": 5700
}
},
{
"label": "child_in_stroller",
"confidence": 0.97,
"bbox": {
"x1": 800,
"y1": 2400,
"x2": 1600,
"y2": 5300
}
},
{
"label": "accordion",
"confidence": 0.99,
"bbox": {
"x1": 2700,
"y1": 3400,
"x2": 3600,
"y2": 5000
}
},
{
"label": "baby",
"confidence": 0.98,
"bbox": {
"x1": 1000,
"y1": 2700,
"x2": 1450,
"y2": 3600
}
},
{
"label": "baby_bottle",
"confidence": 0.98,
"bbox": {
"x1": 1100,
"y1": 3050,
"x2": 1200,
"y2": 3400
}
},
{
"label": "stroller",
"confidence": 0.97,
"bbox": {
"x1": 850,
"y1": 2300,
"x2": 1650,
"y2": 5400
}
},
{
"label": "box",
"confidence": 0.97,
"bbox": {
"x1": 2000,
"y1": 5300,
"x2": 2600,
"y2": 5600
}
},
{
"label": "shoe_1",
"confidence": 0.94,
"bbox": {
"x1": 3000,
"y1": 5500,
"x2": 3200,
"y2": 5700
}
},
{
"label": "shoe_2",
"confidence": 0.94,
"bbox": {
"x1": 3300,
"y1": 5500,
"x2": 3500,
"y2": 5700
}
},
{
"label": "signboard",
"confidence": 0.96,
"bbox": {
"x1": 2800,
"y1": 400,
"x2": 3200,
"y2": 750
}
}
]
}
Constatering – LLM met vision schalen de input foto #
Toen ik bovenstaande prompt uitvoerde zag ik meteen dat de foto die ik in LM studio zet 853×1280 pixels is, in de output json staat: { “source_size”: { “w”: 3840, “h”: 5760 },
ChatGPT zij daarover:
Ahh — dit is een hele goeie observatie, en die getallen verraden meteen wat er gebeurt.
853×1280 → 3840×5760 is exact ×4.5
Reken even mee:
- 853 × 4.5 = 3838.5 → afgerond 3840
- 1280 × 4.5 = 5760 (exact)
Dus die source_size die je ziet is niet “random”: het is jouw originele foto die intern naar ongeveer 4.5× is opgeschaald (en netjes afgerond naar een “mooie” breedte van 3840).
Upscaled LM Studio de base64?
In de praktijk: ja, dat kan — niet omdat base64 “magisch” upscaled, maar omdat LM Studio vóór het encoden vaak een preprocessing stap doet:
- jij dropt een JPG (853×1280)
- LM Studio maakt er intern een “model input image” van
- resize / resample (soms downscale, soms upscale)
- vaak naar een target short-side/long-side of max pixels
- díe verwerkte image wordt naar bytes omgezet en als base64 naar het model gestuurd
- het model redeneert/rapporteert bounding boxes in de pixelruimte van die verwerkte image
Dus: base64 is gewoon een verpakking; de inhoud (de image bytes) kan door LM Studio wel degelijk resized zijn.
Waarom zou LM Studio überhaupt upscalen?
Dat hangt af van de vision-adapter / mmproj pipeline. Veel VLM’s werken met een vaste “vision encoder” resolutie of een “detail mode” waar de input naar een bepaalde schaal wordt gebracht om meer detail te behouden. Soms wordt er ook ge-“letterboxed” of naar een multiple-of-32/64 afgerond.
Dat je exact op 3840×5760 uitkomt suggereert:
- er is een “doelschaal” gekozen (bijv. max long side ~5760), of
- er is een vaste upscale factor toegepast voor vision (“high detail”), en dan afgerond.
Belangrijk: jouw LLM kan source_size ook hallucineren
Maar: in jouw geval matcht het zó precies ×4.5 dat dit waarschijnlijk echt uit de LM Studio metadata/pipeline komt (of het model heeft de getallen uit zichtbare metadata/EXIF-achtige hints afgeleid). Als jij dat .metadata.json opent, zie je vaak velden die dit bevestigen.
Wat betekent dit voor je script?
Eigenlijk: niets ergs — het is juist waarom source_size handig is.
- Als Mistral bbox-coords geeft in 3840×5760,
- en jouw echte afbeelding is 853×1280,
- dan schaalt je tool terug met:
sx = 853 / 3840
sy = 1280 / 5760
En dan vallen boxes precies goed.
Extra tip (nog robuuster): normalized bboxes
Als je klaar bent met deze stap, is de meest “kogelvrije” variant:
- laat het model óók
bbox_normgeven (0–1 floats)
Dan maakt het niet uit of LM Studio up/downscaled, cropped, etc. (mits dezelfde aspect-ratio en geen crop). Maar voor nu issource_sizeal top.
Prompt – Strakkere bbox #
De prompt met de vraag voor een strakkere bbox is als volgt:
Geef alleen geldig JSON terug met maximaal 10 objecten.
Het format van de JSON schema moet zijn:
{
"source_size": { "src_w": int, "src_h": int },
"detections": [
{
"label": labelstring,
"confidence": confidencefloat,
"bbox": { "x1": int, "y1": int, "x2": int, "y2": int }
}
]
}
De variabelen zijn:
source_size = de resolutie src_w=width, src_h=height waarop je de bounding box pixels baseert in een integer waarde.
labelstring = Het object wat je herkent op de foto
confidencefloat = De inschatting (of zekerheid) in 0–1 float waarde dat je denk wat het object is wat je hebt omschreven.
x1 en y1 = Een coördinaat in pixels van de boundingbox linksboven van het object in een integer waarde.
x2 en y2 = Een coördinaat in pixels van de boundingbox rechtsonder van het object in een integer waarde.
Aandachtspunten:
1. De pixels of coördinaten van de boudingboxes mag een kleine marge hebben.
2. Maak bounding boxes zo strak mogelijk om het object, niet om omgeving/achtergrond.
3. Benoem de objecten van groot naar klein.
4. Benoem maximaal 10 objecten, dieren of personen.
5. Het allerkleinste detail hoef je niet te benoemen.
6. Gebruik unieke labels waar mogelijk, als hetzelfde object meerdere keren voorkomt, voeg een suffix toe zoals: shoe_1, shoe_2.
Hieronder het aangepaste script met de aanpassingen van ChatGPT:
A) Lees source_size uit
B) Bepaal schaalfactoren
C) Schaal de bounding boxes
Als source_size ontbreekt > fallback naar 1:1 > niks breekt
import json
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
def clamp(v, lo, hi):
return max(lo, min(hi, v))
def main():
if len(sys.argv) < 3:
print("Usage: python draw_bboxes.py input.jpg output.jpg [detections.json]")
print("If detections.json is omitted, JSON is read from stdin.")
sys.exit(1)
in_path = Path(sys.argv[1])
out_path = Path(sys.argv[2])
# JSON inlezen
if len(sys.argv) >= 4:
det_path = Path(sys.argv[3])
data = json.loads(det_path.read_text(encoding="utf-8"))
else:
data = json.loads(sys.stdin.read())
img = Image.open(in_path).convert("RGB")
W, H = img.size
# source_size lezen (let op: jij gebruikt src_w/src_h in je JSON)
source = data.get("source_size", {}) or {}
src_w = int(source.get("src_w", 0))
src_h = int(source.get("src_h", 0))
# Schaalfactoren (default = 1:1)
sx = 1.0
sy = 1.0
if src_w > 0 and src_h > 0:
sx = W / src_w
sy = H / src_h
draw = ImageDraw.Draw(img)
# Font proberen te laden; anders fallback
try:
font = ImageFont.truetype("arial.ttf", 16)
except Exception:
font = ImageFont.load_default()
detections = data.get("detections", []) or []
for det in detections:
label = str(det.get("label", ""))
conf = det.get("confidence", None)
bbox = det.get("bbox", {}) or {}
# Schalen naar echte image-resolutie
x1 = int(float(bbox.get("x1", 0)) * sx)
y1 = int(float(bbox.get("y1", 0)) * sy)
x2 = int(float(bbox.get("x2", 0)) * sx)
y2 = int(float(bbox.get("y2", 0)) * sy)
# Clamp binnen image bounds
x1 = clamp(x1, 0, W - 1)
y1 = clamp(y1, 0, H - 1)
x2 = clamp(x2, 0, W - 1)
y2 = clamp(y2, 0, H - 1)
# Normaliseer als ze omgedraaid zijn
if x2 < x1: x1, x2 = x2, x1
if y2 < y1: y1, y2 = y2, y1
# Tekst
if isinstance(conf, (int, float)):
txt = f"{label} {conf:.2f}"
else:
txt = label
# Rectangle dikte
thickness = 3
for t in range(thickness):
draw.rectangle([x1 - t, y1 - t, x2 + t, y2 + t])
# Label achtergrond (simpel)
# textbbox -> (x0, y0, x1, y1)
tb = draw.textbbox((0, 0), txt, font=font)
tw = tb[2] - tb[0]
th = tb[3] - tb[1]
pad = 2
bx1 = x1
by1 = max(0, y1 - (th + pad * 2))
bx2 = clamp(x1 + tw + pad * 2, 0, W)
by2 = clamp(by1 + th + pad * 2, 0, H)
draw.rectangle([bx1, by1, bx2, by2]) # outline only
draw.text((bx1 + pad, by1 + pad), txt, font=font)
img.save(out_path, quality=95)
print(f"Saved: {out_path}")
if __name__ == "__main__":
main()
Resultaat:

Zoals je ziet, geeft het schalen al iets meer resultaat, maar is ver van perfect!
Script – Aanpassingen #
Ik heb bovenstaande output in ChatGPT gezet en hij had nog wat tips:
1) Overlap / “dubbele” boxes
strollerenchild_in_strolleroverlappen bijna volledigbabyzit logisch binnenstroller
Dit is prima; het model detecteert meerdere “concepten” op dezelfde regio.
Als je dit netter wilt: voeg in je script een simpele NMS/IoU-filter toe per label (of algemeen) zodat je alleen de beste box houdt als ze te veel overlappen.
- als IoU > 0.8 → houd alleen hoogste confidence
(werkt top om dubbele stroller-boxen weg te halen)
Ik kan ’m zo schrijven dat je het met één variabele aan/uit zet, bv:
IOU_THRESHOLD = 0.80
2) Labels op de boxrand
Je labels zijn nu wit met outline-only achtergrond, waardoor ze soms “verdwijnen” op lichte stukken.
Quick fix: maak het label-vlakje gevuld (semi-opaque kan ook). Simpelste: fill="black" en tekst in wit.
Ik heb het zo gemaakt dat je bovenin makkelijk kunt tweaken:
IOU_THRESHOLD = 0.80NMS_MODE = "per_label"(aanrader) of"global"of"off"
Python script met aanpassingen:
import json
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
# =========================
# Instellingen
# =========================
IOU_THRESHOLD = 0.80 # als IoU hoger is dan dit, wordt de zwakkere box verwijderd
NMS_MODE = "per_label" # "per_label" (aanrader) | "global" | "off"
BOX_THICKNESS = 3 # lijn dikte van bounding box
FONT_SIZE = 16 # label font size
LABEL_PADDING = 3 # padding in label-vlak
LABEL_ALPHA = 200 # 0..255 (alleen gebruikt als we RGBA overlay doen)
def clamp(v, lo, hi):
return max(lo, min(hi, v))
def iou(a, b):
"""Intersection over Union tussen 2 boxes: a,b = (x1,y1,x2,y2)"""
ax1, ay1, ax2, ay2 = a
bx1, by1, bx2, by2 = b
inter_x1 = max(ax1, bx1)
inter_y1 = max(ay1, by1)
inter_x2 = min(ax2, bx2)
inter_y2 = min(ay2, by2)
iw = max(0, inter_x2 - inter_x1)
ih = max(0, inter_y2 - inter_y1)
inter = iw * ih
area_a = max(0, ax2 - ax1) * max(0, ay2 - ay1)
area_b = max(0, bx2 - bx1) * max(0, by2 - by1)
denom = area_a + area_b - inter
if denom <= 0:
return 0.0
return inter / denom
def nms(detections, iou_thr=0.8, mode="per_label"):
"""
Non-Maximum Suppression.
- per_label: alleen overlap binnen hetzelfde label wordt gefilterd
- global: overlap over alle labels wordt gefilterd (agressiever)
- off: niks doen
"""
if mode == "off":
return detections
# Sorteer op confidence (hoog -> laag)
dets = sorted(detections, key=lambda d: float(d.get("confidence", 0.0)), reverse=True)
kept = []
for d in dets:
box_d = d["_box"]
label_d = d.get("label", "")
suppress = False
for k in kept:
if mode == "per_label" and k.get("label", "") != label_d:
continue
if iou(box_d, k["_box"]) > iou_thr:
suppress = True
break
if not suppress:
kept.append(d)
return kept
def main():
if len(sys.argv) < 3:
print("Usage: python draw_bboxes.py input.jpg output.jpg [detections.json]")
print("If detections.json is omitted, JSON is read from stdin.")
sys.exit(1)
in_path = Path(sys.argv[1])
out_path = Path(sys.argv[2])
# JSON inlezen
if len(sys.argv) >= 4:
det_path = Path(sys.argv[3])
data = json.loads(det_path.read_text(encoding="utf-8"))
else:
data = json.loads(sys.stdin.read())
# Image openen
img_rgb = Image.open(in_path).convert("RGB")
W, H = img_rgb.size
# source_size lezen
source = data.get("source_size", {}) or {}
# ondersteunt zowel {"w":..,"h":..} als {"src_w":..,"src_h":..}
src_w = int(source.get("w", source.get("src_w", 0)) or 0)
src_h = int(source.get("h", source.get("src_h", 0)) or 0)
# Schaalfactoren (default = 1:1)
sx = 1.0
sy = 1.0
if src_w > 0 and src_h > 0:
sx = W / src_w
sy = H / src_h
# Font proberen te laden; anders fallback
try:
font = ImageFont.truetype("arial.ttf", FONT_SIZE)
except Exception:
font = ImageFont.load_default()
detections_in = data.get("detections", []) or []
# Preprocess: schaal boxes naar echte resolutie + clamp + normalize
dets = []
for det in detections_in:
label = str(det.get("label", ""))
conf = det.get("confidence", 0.0)
bbox = det.get("bbox", {}) or {}
x1 = int(float(bbox.get("x1", 0)) * sx)
y1 = int(float(bbox.get("y1", 0)) * sy)
x2 = int(float(bbox.get("x2", 0)) * sx)
y2 = int(float(bbox.get("y2", 0)) * sy)
# Clamp binnen bounds
x1 = clamp(x1, 0, W - 1)
y1 = clamp(y1, 0, H - 1)
x2 = clamp(x2, 0, W - 1)
y2 = clamp(y2, 0, H - 1)
# Normaliseer omgedraaide coords
if x2 < x1:
x1, x2 = x2, x1
if y2 < y1:
y1, y2 = y2, y1
# Skip lege box
if (x2 - x1) < 2 or (y2 - y1) < 2:
continue
dets.append({
"label": label,
"confidence": float(conf) if isinstance(conf, (int, float)) else 0.0,
"_box": (x1, y1, x2, y2),
})
# NMS toepassen (IoU overlap filter)
dets = nms(dets, iou_thr=IOU_THRESHOLD, mode=NMS_MODE)
# We tekenen in RGBA voor semi-opaque label backgrounds
img = img_rgb.convert("RGBA")
overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
draw_overlay = ImageDraw.Draw(overlay)
draw = ImageDraw.Draw(img)
for det in dets:
label = det["label"]
conf = det["confidence"]
x1, y1, x2, y2 = det["_box"]
txt = f"{label} {conf:.2f}"
# 1) Bounding box tekenen (wit)
for t in range(BOX_THICKNESS):
draw.rectangle([x1 - t, y1 - t, x2 + t, y2 + t], outline=(255, 255, 255, 255))
# 2) Label (zwart gevuld, wit text)
tb = draw.textbbox((0, 0), txt, font=font)
tw = tb[2] - tb[0]
th = tb[3] - tb[1]
pad = LABEL_PADDING
bx1 = x1
by1 = max(0, y1 - (th + pad * 2))
bx2 = clamp(x1 + tw + pad * 2, 0, W)
by2 = clamp(by1 + th + pad * 2, 0, H)
# semi-opaque zwart label-vlak op overlay
draw_overlay.rectangle([bx1, by1, bx2, by2], fill=(0, 0, 0, LABEL_ALPHA))
# groene tekst op de echte draw
draw_overlay.text((bx1 + pad, by1 + pad), txt, font=font, fill=(0, 255, 0, 255))
# Overlay compositen en terug naar RGB opslaan
out = Image.alpha_composite(img, overlay).convert("RGB")
out.save(out_path, quality=95)
print(f"Saved: {out_path}")
if __name__ == "__main__":
main()
Nieuwe run met detections op ministral-3-14b-instruct-2512:
detections.json
{
"source_size": {
"src_w": 3840,
"src_h": 5760
},
"detections": [
{
"label": "person",
"confidence": 0.98,
"bbox": {
"x1": 2300,
"y1": 2400,
"x2": 3750,
"y2": 5600
}
},
{
"label": "baby_in_stroller",
"confidence": 0.97,
"bbox": {
"x1": 800,
"y1": 2300,
"x2": 1650,
"y2": 4800
}
},
{
"label": "accordion",
"confidence": 0.99,
"bbox": {
"x1": 3100,
"y1": 2700,
"x2": 3650,
"y2": 4200
}
},
{
"label": "baby_bottle",
"confidence": 0.98,
"bbox": {
"x1": 1000,
"y1": 3000,
"x2": 1150,
"y2": 3400
}
},
{
"label": "stroller",
"confidence": 0.97,
"bbox": {
"x1": 600,
"y1": 2200,
"x2": 1850,
"y2": 4900
}
},
{
"label": "box",
"confidence": 0.93,
"bbox": {
"x1": 2700,
"y1": 5000,
"x2": 3600,
"y2": 5400
}
},
{
"label": "shoe_1",
"confidence": 0.95,
"bbox": {
"x1": 3700,
"y1": 5200,
"x2": 3850,
"y2": 5400
}
},
{
"label": "shoe_2",
"confidence": 0.96,
"bbox": {
"x1": 3700,
"y1": 5400,
"x2": 3850,
"y2": 5600
}
},
{
"label": "sign",
"confidence": 0.92,
"bbox": {
"x1": 2700,
"y1": 400,
"x2": 3000,
"y2": 850
}
},
{
"label": "bag",
"confidence": 0.96,
"bbox": {
"x1": 2700,
"y1": 4800,
"x2": 3000,
"y2": 5200
}
}
]
}
Resultaat:

Nog steeds ver van perfect!
Oplossing – Het kwartje gaat vallen! #
Ik dacht jeetje….QWEN3 en MINISTRAL3 al geprobeerd laat ik eens GLM-4.6V proberen (glm-4.6v-flash) deze heeft ook thought/thinking dus we kunnen meekijken in het denkproces.
Output denkproces:
Got it, let's tackle this task step by step. First, I need to analyze the image and identify the objects according to the requirements.
First, look at the source_size. The image resolution—let's assume we can get that from typical image dimensions, but since we don't have exact numbers, maybe we can estimate or just provide placeholder? Wait no, the user probably expects us to infer based on common practices. But actually, in the JSON structure, "source_size" is required with src_w and src_h as integers.
Looking at the image: there's a person (woman) playing an accordion, a baby in a stroller, a stroller itself, maybe shoes, a box on the ground.
Let's list the objects from largest to smallest:
Person (the woman): She's sitting, so label "person".
Accordion: The musical instrument she's holding.
Stroller: The baby carriage.
Baby: In the stroller.
Box: On the ground in front of her.
Shoes? Maybe her shoes or slippers.
Wait, need to check each element:
The main subject is a woman with an accordion. So "person" as label for her.
Accordion: clearly visible, so "accordion".
Stroller: black stroller next to her.
Baby in the stroller: "baby".
Box on the ground: "box".
Maybe shoes (her footwear): "shoe_1".
Now, need to define bounding boxes. Let's estimate coordinates.
First, source_size: We need to find src_w and src_h. Looking at the image, typical photo size might be around 1200x800 or similar, but let's assume we can get approximate values. But maybe in the context of this task, we just provide reasonable numbers. Wait, no—actually, for the JSON, source_size should reflect the actual resolution of the input image. Since we don't have that data, perhaps we can use common values, or maybe the user expects us to infer from the content.
But let's proceed with estimating:
Let's assume src_w = 1200, src_h = 800 (just example). But wait, no—maybe better to look at the image and estimate bounding boxes for each object.
First, person: The woman sitting. Her position: x1 around 460, y1 around 485, x2 around 865, y2 around 915.
Accordion: She's holding it, so bounding box from her hands. Let's say x1=567, y1=603, x2=867, y2=763 (since the accordion is in front of her).
AHA!, het model is gewoon aan het schatten want het weet de resolutie niet, hoe kun je dan bounding boxes tekenen?
Dit voorgelegd aan ChatGPT:
Klopt — een VLM/LLM krijgt meestal níet automatisch de “originele resolutie” als harde metadata. Hij krijgt pixels (of patches/tiles) na preprocessing van de client (LM Studio), en die preprocessing kan:
- resizen (downscale of upscale)
- afronden naar multiples (32/64)
- eventueel letterboxen/padding/croppen
- tilen / “stukjes” maken voor detail
Daarom zie je bij GLM in je “thought” precies dit gedrag: hij moet gokken en gaat dan “1200×800” verzinnen. Dat is geen “domheid”, maar gewoon: die info zit niet betrouwbaar in de input.
Waarom “stukjes+stukjes” niet automatisch width/height oplevert
Ook als het model “stukje voor stukje” kijkt, betekent dat niet dat het weet:
- hoeveel tiles er zijn,
- hoe tiles zijn overlapt,
- of er padding/letterbox is,
- of de client eerst rescaled heeft,
- wat de exacte pixel-dimensies van dat canvas zijn.
Voor het model zijn het vaak gewoon embeddings/patches. Zonder expliciete metadata is het dus niet triviaal om terug te rekenen naar exacte W/H.
Wat LM Studio precies doet
LM Studio stuurt doorgaans niet jouw originele JPG bytes 1:1 naar het model. Het maakt een “model-input image” (resized/normalized), en dat gaat base64 in. Daarom kon jij ook die 3840×5760 zien: dat kwam uit preprocessing (opgeschaald), niet uit je originele 853×1280.
Base64 itself “upscaled” niets; LM Studio heeft een nieuwe image gemaakt en die ge-encoded.
Oplossing – Genormaliseerde waarden #
Beste praktijk (werkt met elk model)
Laat LM Studio / jouw tool de echte resolutie leveren, niet de LLM.
Dus in plaats van:
- “LLM: zet source_size goed”
doe je:
- jouw Python tool leest de echte input image size (W,H)
- en je gebruikt óf:
- genormaliseerde boxen (0–1), of
source_sizedat de client/pipeline levert (niet de LLM)
Aanrader A: normalized bbox (meest robuust)
Laat de LLM dit teruggeven:
"bbox_norm": { "x1":0.12, "y1":0.34, "x2":0.56, "y2":0.91 }
Dan kan jouw script altijd doen:
x = bbox_norm.x * Wy = bbox_norm.y * H
Dan maakt resizing/tiling niet uit (zolang geen crop; bij letterbox moet je nog even corrigeren, maar ook dat is oplosbaar).
De LLM weet de originele resolutie meestal niet en gaat dat vaak gokken (zoals GLM nu doet).
Voor jouw pipeline is daarom het beste:
- laat de LLM alleen detecties geven,
- en laat jouw tool de echte W/H bepalen (en idealiter met
bbox_normwerken).
Als je wil, geef ik je een aangepaste prompt die altijd normalized boxes produceert (en een kleine patch in je script om normalized → pixels te tekenen). Dat maakt je hele keten veel stabieler, ook als LM Studio morgen z’n preprocessing wijzigt.
LLMs denken relatief, niet absoluut
Een VLM “ziet” geen echte pixels zoals YOLO dat doet. Het redeneert in:
- links / rechts
- boven / onder
- ongeveer halverwege
- klein / groot
Dat map je direct op 0–1 coördinaten:
| Menselijk | Genormaliseerd |
|---|---|
| linksboven | (0.05, 0.10) |
| midden | (0.45–0.55) |
| rechtsonder | (0.85–0.95) |
Pixels vereisen eerst een mentale resolutie-schatting → daar gaat het mis.
Pixels dwingen de LLM te gokken
Zoals je bij GLM zag:
“Let’s assume src_w = 1200, src_h = 800”
Dat is onvermijdelijk, want:
- de resolutie is niet betrouwbaar bekend
- preprocessing (resize/tiling) maakt het nog erger
Bij normalized boxes:
- geen resolutie nodig
- geen gokken
- geen hallucinatie
Stabiel bij resizing / upscaling / tiling
Jij zag zelf:
- 853×1280 → 3840×5760 intern
Met pixels breekt dat.
Met normalized:
x1 = bbox_norm["x1"] * W
y1 = bbox_norm["y1"] * H
→ altijd correct.
Consistentere box-vorm
LLMs geven bij normalized boxes:
- minder “weggeschoten” boxes
- strakkere verhoudingen
- minder overlap-fouten
Omdat ze niet hoeven te rekenen met grote getallen.
Aanbevolen JSON schema (beste vorm)
Dit is wat ik in jouw situatie zou gebruiken:
{
"detections": [
{
"label": "person",
"confidence": 0.98,
"bbox_norm": {
"x1": 0.52,
"y1": 0.38,
"x2": 0.87,
"y2": 0.93
}
}
]
}
Regels:
- floats 0.00 – 1.00
- 2 decimalen is perfect
- linksboven → rechtsonder
- géén source_size nodig
Waarom dit nu “de eindvorm” is
- LLM hoeft geen resolutie te raden
- Ongevoelig voor LM Studio preprocessing
- Werkt met elk vision-LLM
- Tool is simpel, stabiel en herbruikbaar
- Klaar voor MCP-tooling
Je zit nu op het punt waar veel professionele pipelines eindigen: LLM → normalized detections → deterministic tool.
Prompt 6 – Genormaliseerde waarden #
De volgende prompt laat de LLM geen pixels maar genormaliseerde waarden teruggeven, de aangepaste prompt:
Geef alleen geldig JSON terug met maximaal 10 objecten.
Het format van het JSON schema moet zijn:
{
"detections": [
{
"label": string,
"confidence": float,
"bbox_norm": { "x1": float, "y1": float, "x2": float, "y2": float }
}
]
}
De variabelen zijn:
label = Het object wat je herkent op de foto.
confidence = De inschatting (of zekerheid) in 0–1 float waarde dat je denk wat het object is wat je hebt omschreven.
x1, y1 = genormaliseerde linker bovenhoek van de bounding box met float waarden tussen 0.00 en 1.00.
x2, y2 = genormaliseerde rechter onderhoek van de bounding box met float waarden tussen 0.00 en 1.00.
Aandachtspunten:
1. Bounding boxes zijn relatief t.o.v. de volledige afbeelding.
2. Gebruik alleen genormaliseerde coördinaten (geen pixels).
3. Maak bounding boxes zo strak mogelijk om het object, niet om omgeving of achtergrond.
4. Benoem objecten van groot naar klein.
5. Benoem maximaal 10 objecten, mensen of dieren.
6. Het allerkleinste detail hoef je niet te benoemen.
7. Gebruik unieke labels waar mogelijk; als hetzelfde object meerdere keren voorkomt, voeg een suffix toe zoals: shoe_1, shoe_2.
8. Geef uitsluitend JSON terug, zonder extra tekst of uitleg.
De output detection.json:
{
"detections": [
{
"label": "person",
"confidence": 0.98,
"bbox_norm": { "x1": 0.52, "y1": 0.34, "x2": 0.87, "y2": 0.96 }
},
{
"label": "accordion",
"confidence": 0.97,
"bbox_norm": { "x1": 0.65, "y1": 0.43, "x2": 0.87, "y2": 0.75 }
},
{
"label": "stroller",
"confidence": 0.95,
"bbox_norm": { "x1": 0.30, "y1": 0.46, "x2": 0.58, "y2": 0.97 }
},
{
"label": "baby",
"confidence": 0.93,
"bbox_norm": { "x1": 0.38, "y1": 0.46, "x2": 0.50, "y2": 0.70 }
},
{
"label": "bottle",
"confidence": 0.90,
"bbox_norm": { "x1": 0.43, "y1": 0.58, "x2": 0.46, "y2": 0.63 }
},
{
"label": "shoe_1",
"confidence": 0.87,
"bbox_norm": { "x1": 0.79, "y1": 0.92, "x2": 0.84, "y2": 0.96 }
},
{
"label": "shoe_2",
"confidence": 0.85,
"bbox_norm": { "x1": 0.73, "y1": 0.92, "x2": 0.78, "y2": 0.96 }
},
{
"label": "box",
"confidence": 0.80,
"bbox_norm": { "x1": 0.45, "y1": 0.93, "x2": 0.60, "y2": 0.97 }
},
{
"label": "wheel_1",
"confidence": 0.82,
"bbox_norm": { "x1": 0.45, "y1": 0.83, "x2": 0.50, "y2": 0.90 }
},
{
"label": "wheel_2",
"confidence": 0.78,
"bbox_norm": { "x1": 0.49, "y1": 0.83, "x2": 0.54, "y2": 0.90 }
}
]
}
Ook heb ik samen met ChatGPT het script aangepast dat nu genormaliseerde waarden gebruikt:
import json
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
# =========================
# Instellingen
# =========================
IOU_THRESHOLD = 0.80 # overlap-drempel
NMS_MODE = "per_label" # "per_label" | "global" | "off"
BOX_THICKNESS = 3
FONT_SIZE = 16
LABEL_PADDING = 3
LABEL_ALPHA = 200 # 0..255 (label achtergrond transparantie)
def clamp(v, lo, hi):
return max(lo, min(hi, v))
def iou(a, b):
"""Intersection over Union tussen 2 boxes: (x1,y1,x2,y2)"""
ax1, ay1, ax2, ay2 = a
bx1, by1, bx2, by2 = b
ix1 = max(ax1, bx1)
iy1 = max(ay1, by1)
ix2 = min(ax2, bx2)
iy2 = min(ay2, by2)
iw = max(0, ix2 - ix1)
ih = max(0, iy2 - iy1)
inter = iw * ih
area_a = max(0, ax2 - ax1) * max(0, ay2 - ay1)
area_b = max(0, bx2 - bx1) * max(0, by2 - by1)
denom = area_a + area_b - inter
if denom <= 0:
return 0.0
return inter / denom
def nms(detections, iou_thr=0.8, mode="per_label"):
if mode == "off":
return detections
dets = sorted(detections, key=lambda d: d["confidence"], reverse=True)
kept = []
for d in dets:
suppress = False
for k in kept:
if mode == "per_label" and k["label"] != d["label"]:
continue
if iou(d["_box"], k["_box"]) > iou_thr:
suppress = True
break
if not suppress:
kept.append(d)
return kept
def main():
if len(sys.argv) < 3:
print("Usage: python draw_bboxes_norm.py input.jpg output.jpg [detections.json]")
print("If detections.json is omitted, JSON is read from stdin.")
sys.exit(1)
in_path = Path(sys.argv[1])
out_path = Path(sys.argv[2])
# JSON inlezen
if len(sys.argv) >= 4:
data = json.loads(Path(sys.argv[3]).read_text(encoding="utf-8"))
else:
data = json.loads(sys.stdin.read())
img_rgb = Image.open(in_path).convert("RGB")
W, H = img_rgb.size
# Font laden
try:
font = ImageFont.truetype("arial.ttf", FONT_SIZE)
except Exception:
font = ImageFont.load_default()
detections_in = data.get("detections", []) or []
dets = []
# === Normalized → pixel coords ===
for det in detections_in:
label = str(det.get("label", ""))
conf = det.get("confidence", 0.0)
bn = det.get("bbox_norm", {}) or {}
try:
x1 = int(float(bn["x1"]) * W)
y1 = int(float(bn["y1"]) * H)
x2 = int(float(bn["x2"]) * W)
y2 = int(float(bn["y2"]) * H)
except Exception:
continue
# Clamp
x1 = clamp(x1, 0, W - 1)
y1 = clamp(y1, 0, H - 1)
x2 = clamp(x2, 0, W - 1)
y2 = clamp(y2, 0, H - 1)
if x2 < x1:
x1, x2 = x2, x1
if y2 < y1:
y1, y2 = y2, y1
if (x2 - x1) < 2 or (y2 - y1) < 2:
continue
dets.append({
"label": label,
"confidence": float(conf),
"_box": (x1, y1, x2, y2)
})
# === IoU overlap filter ===
dets = nms(dets, iou_thr=IOU_THRESHOLD, mode=NMS_MODE)
# === Tekenfase ===
img = img_rgb.convert("RGBA")
overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
draw_overlay = ImageDraw.Draw(overlay)
draw = ImageDraw.Draw(img)
for det in dets:
label = det["label"]
conf = det["confidence"]
x1, y1, x2, y2 = det["_box"]
txt = f"{label} {conf:.2f}"
# Bounding box
for t in range(BOX_THICKNESS):
draw.rectangle([x1 - t, y1 - t, x2 + t, y2 + t],
outline=(255, 255, 255, 255))
# Label box
tb = draw.textbbox((0, 0), txt, font=font)
tw = tb[2] - tb[0]
th = tb[3] - tb[1]
pad = LABEL_PADDING
bx1 = x1
by1 = max(0, y1 - (th + pad * 2))
bx2 = clamp(x1 + tw + pad * 2, 0, W)
by2 = clamp(by1 + th + pad * 2, 0, H)
draw_overlay.rectangle([bx1, by1, bx2, by2],
fill=(0, 0, 0, LABEL_ALPHA))
draw_overlay.text((bx1 + pad, by1 + pad),
txt, font=font, fill=(0, 255, 0, 255))
out = Image.alpha_composite(img, overlay).convert("RGB")
out.save(out_path, quality=95)
print(f"Saved: {out_path}")
if __name__ == "__main__":
main()
De tekstkleur heb ik hier aangepast naar groen! (op onderstaande foto’s was deze nog wit)
Resultaat:

Komt in de buurt, maar ver van perfect!
Prompt 7 – Geen attribuut labels, beperk grote boxes #
ChatGPT had nog wat tips van bovenstaande foto:
1) Prompt: verbied “attribuut-labels” zoals adult, clothes_adult
LLMs gaan graag combineren: person_adult, clothes_adult, etc.
Zet erbij:
- “Gebruik korte objectnamen (1–2 woorden), geen attributen zoals adult/young/clothes_*.”
- “Gebruik labels zoals: person, baby, stroller, accordion, bottle, shoe_1 …”
2) Prompt: beperk ‘grote’ boxes
Voeg 1 regel toe:
- “Geef geen bounding box die meer dan 60% van de afbeelding bedekt, tenzij het object echt zo groot is.”
(LLMs maken anders vaak een “safe” mega-box.)
De aangepaste prompt:
Geef alleen geldig JSON terug met maximaal 10 objecten.
Het format van het JSON schema moet zijn:
{
"detections": [
{
"label": string,
"confidence": float,
"bbox_norm": { "x1": float, "y1": float, "x2": float, "y2": float }
}
]
}
De variabelen zijn:
label = Het object wat je herkent op de foto.
confidence = De inschatting (of zekerheid) dat je denk wat het object is wat je hebt omschreven (float tussen 0.00 en 1.00).
x1, y1 = Genormaliseerde linker bovenhoek van de bounding box (float tussen 0.00 en 1.00).
x2, y2 = Genormaliseerde rechter onderhoek van de bounding box (float tussen 0.00 en 1.00).
Aandachtspunten:
1. Bounding boxes zijn relatief t.o.v. de volledige afbeelding.
2. Gebruik alleen genormaliseerde coördinaten (geen pixels).
3. Maak bounding boxes zo strak mogelijk om het object, niet om omgeving of achtergrond.
4. Geef geen bounding box die meer dan 60% van de afbeelding bedekt, tenzij het object echt zo groot is.
5. Benoem objecten van groot naar klein.
6. Gebruik korte objectnamen (1–2 woorden), geen attributen zoals adult/young/clothes_*.
7. Gebruik labels zoals: person, baby, stroller, accordion, bottle, shoe_1
8. Benoem maximaal 10 objecten, mensen of dieren.
9. Het allerkleinste detail hoef je niet te benoemen.
10. Gebruik unieke labels waar mogelijk; als hetzelfde object meerdere keren voorkomt, voeg een suffix toe zoals: shoe_1, shoe_2.
11. Geef uitsluitend JSON terug, zonder extra tekst of uitleg.
Resultaat:

Wauw, het wordt steeds nauwkeuriger dacht ik zo, maar is dit het beste wat we kunnen krijgen?
Oplossing – Gebruik de juiste LLM met Vision voor de job! #
Nadat ik bovenstaande foto heb gedeeld met ChatGPT kreeg ik de volgende response:
Wat ik je NU zou aanraden (volgende stap)
Je bent op het punt gekomen waar vergelijken zinvol wordt.
Probeer nu Qwen3-VL met exact dezelfde prompt
Wat je waarschijnlijk gaat zien:
- strakkere accordeon-box
- betere begrenzing van
person - minder “verticale rek” in boxes
- iets conservatievere kleine objecten
Dus heb ik maar eens QWEN3 VL weer geprobeerd, maar nu met genormaliseerde float bbox waarden en een betere prompt, het resultaat is al verbluffend (beter!)
detections.json
{
"detections": [
{
"label": "person",
"confidence": 0.98,
"bbox_norm": {
"x1": 0.46,
"y1": 0.49,
"x2": 0.83,
"y2": 0.92
}
},
{
"label": "baby",
"confidence": 0.97,
"bbox_norm": {
"x1": 0.26,
"y1": 0.6,
"x2": 0.43,
"y2": 0.76
}
},
{
"label": "stroller",
"confidence": 0.96,
"bbox_norm": {
"x1": 0.14,
"y1": 0.53,
"x2": 0.53,
"y2": 0.92
}
},
{
"label": "accordion",
"confidence": 0.95,
"bbox_norm": {
"x1": 0.54,
"y1": 0.61,
"x2": 0.86,
"y2": 0.78
}
},
{
"label": "bottle",
"confidence": 0.94,
"bbox_norm": {
"x1": 0.32,
"y1": 0.64,
"x2": 0.38,
"y2": 0.68
}
},
{
"label": "shoe_1",
"confidence": 0.93,
"bbox_norm": {
"x1": 0.69,
"y1": 0.87,
"x2": 0.77,
"y2": 0.91
}
},
{
"label": "shoe_2",
"confidence": 0.92,
"bbox_norm": {
"x1": 0.53,
"y1": 0.89,
"x2": 0.57,
"y2": 0.92
}
},
{
"label": "box",
"confidence": 0.91,
"bbox_norm": {
"x1": 0.43,
"y1": 0.91,
"x2": 0.63,
"y2": 0.97
}
},
{
"label": "plaque",
"confidence": 0.89,
"bbox_norm": {
"x1": 0.58,
"y1": 0.06,
"x2": 0.73,
"y2": 0.24
}
},
{
"label": "wall",
"confidence": 0.88,
"bbox_norm": {
"x1": 0.0,
"y1": 0.0,
"x2": 0.96,
"y2": 0.82
}
}
]
}
Resultaat:

Samen met ChatGPT zijn we al aardig enthousiast!
JA DIT IS ‘M — dit is precies het moment waarop je ziet:
“oké… dit is geen speelgoed meer.”
Je reactie is 100% terecht. Qwen3-VL-8B zit hier verrassend dicht tegen YOLO aan, zeker voor semantische detectie. En je hebt helemaal gelijk:
Je hebt hier geen pixel-perfectie nodig → je wilt betekenisvolle, consistente boxes
en dát levert Qwen hier keihard.
Waarom dit resultaat zo goed is (technisch gezien)
Qwen snapt object-grenzen, niet alleen “iets daar”
Kijk naar:
accordion→ strak rond het instrumentbaby→ netjes binnen de strollerbottle→ klein, maar correctperson→ geen krankzinnige mega-box meer
Dat betekent:
- beter ruimtelijk begrip
- minder “veilig gokken”
- minder over-enthousiaste box-expansie
Dit is precies waar veel VLM’s (zoals Mistral hier) nét tekortkomen.
Normalized boxes + jouw prompt = gouden combinatie
Wat jij hebt gedaan is cruciaal:
- x geen pixels
- x geen resolutie-gokken
- V relatieve coördinaten
- V max-area regel
- V korte labels
- V expliciete voorbeelden
Qwen volgt regels letterlijker dan Mistral
Dat zie je hier zwart-op-wit.
Semantiek > geometrie (en dat is oké)
YOLO:
- wint op millimeter-niveau
- perfect voor training / tracking / metingen
Qwen3-VL:
- wint op betekenis
- snapt “dit is een baby in een stroller”
- snapt “dit bord hoort bij de muur”
- snapt context
Voor jouw use-case (LLM → tooling → inzicht / overlay / automatisering)
dit is vaak beter dan YOLO!, en dit is nog maar de 8B versie van het model
De 30B gaat waarschijnlijk:
- nóg strakkere accordeon & stroller doen
- minder “wall” detecties geven
- consistenter confidence scores geven
- betere scheiding mens ↔ object
Mijn eerlijke verwachting:
Qwen3-VL-30B is vergelijkbaar met YOLOv5/YOLOv8 op “bruikbaarheid”,
maar met veel meer semantische intelligentie.
Bijna perfectie! – GLM4.6V-flash #
Ik dacht, ik ga nog eens een poging wagen met het model GLM4.6V, WAT!? deze is nog nauwkeuriger dan QWEN3-VL !
Zijn gedachtengang:
Got it, let's analyze the image and extract the relevant information according to the requirements.
First, identify all objects:
Person (the woman playing accordion): She is a prominent figure. So label "person".
Baby: In the stroller, so label "baby".
Stroller: The baby is in it.
Accordion: The instrument she's holding.
Bottle: The baby is drinking from it.
Shoe_1: Her shoe (the blue one).
Box: The white box on the ground.
Now check each object and their bounding boxes normalized.
Let's estimate coordinates:
Person (woman): She's sitting next to the stroller. Let's find x1, y1, x2, y2.
Approximate position: left around 0.46, top ~0.485, right ~0.86, bottom ~0.915.
Baby: In the stroller. Left ~0.25, top ~0.605, right ~0.43, bottom ~0.75.
Stroller: Left ~0.152, top ~0.535, right ~0.54, bottom ~0.952.
Accordion: Held by the person. Left ~0.57, top ~0.608, right ~0.867, bottom ~0.756.
Bottle: In baby's hand. Left ~0.363, top ~0.635, right ~0.405, bottom ~0.658.
Shoe_1 (her blue shoe): Right foot. Left ~0.702, top ~0.867, right ~0.754, bottom ~0.912.
Box: On the ground in front of her. Left ~0.443, top ~0.908, right ~0.643, bottom ~0.971.
Now check if we need max 10 objects and order from largest to smallest.
Let's list them with labels as per rules:
Person (largest)
Stroller
Accordion
Baby
Bottle
Shoe_1
Box
Check confidence: all around high, like 0.95 etc.
Now format into JSON according to schema.
Make sure bounding boxes are normalized.
Let's verify each coordinate range is between 0 and 1.
Proceed to build the JSON.
detections.json (met prompt 7)
{
"detections": [
{
"label": "person",
"confidence": 0.95,
"bbox_norm": { "x1": 0.46, "y1": 0.485, "x2": 0.86, "y2": 0.91 }
},
{
"label": "stroller",
"confidence": 0.95,
"bbox_norm": { "x1": 0.152, "y1": 0.535, "x2": 0.538, "y2": 0.95 }
},
{
"label": "accordion",
"confidence": 0.95,
"bbox_norm": { "x1": 0.573, "y1": 0.608, "x2": 0.867, "y2": 0.756 }
},
{
"label": "baby",
"confidence": 0.95,
"bbox_norm": { "x1": 0.247, "y1": 0.603, "x2": 0.432, "y2": 0.751 }
},
{
"label": "box",
"confidence": 0.95,
"bbox_norm": { "x1": 0.445, "y1": 0.906, "x2": 0.643, "y2": 0.967 }
},
{
"label": "bottle",
"confidence": 0.95,
"bbox_norm": { "x1": 0.355, "y1": 0.636, "x2": 0.403, "y2": 0.661 }
},
{
"label": "shoe_1",
"confidence": 0.95,
"bbox_norm": { "x1": 0.702, "y1": 0.871, "x2": 0.757, "y2": 0.910 }
}
]
}
Resultaat:

Bijna perfectie!
Waarom GLM-4.6V-flash hier wint? GLM is extreem goed in geometrische consistentie
Kijk naar:
person→ netjes rond lichaam, geen muuraccordion→ bijna YOLO-achtig strakbaby→ perfect binnen strollershoe→ klein maar correct- geen rare mega-boxes
GLM is minder “creatief” dan Qwen en daardoor:
- minder semantische drift
- minder “veilig gokken”
- betere relatieve verhoudingen
Dat maakt ’m ideaal voor bounding boxes.
Flash-variant = agressieve visuele focus
De flash varianten zijn vaak:
- minder verbaal
- minder narratief
- meer “kijk → beslis → structureer”
En jouw prompt vraagt exact dát:
“geef JSON, geen uitleg, geen creativiteit”
Dus GLM:
- volgt regels strak
- respecteert max-area
- respecteert “geen attributen”
- respecteert normalized coords
Normalized boxes + GLM = perfecte match
GLM lijkt intern al meer met relatieve posities te werken.
Waar Qwen soms nog “semantic smoothing” doet, blijft GLM dichter bij:
- object-randen
- relatieve schaal
- echte contouren
Daarom zie je:
- minder overlap
- minder verticale rek
- strakkere box-sneden
Prompt 8 – Dwing een codeblock af #
Zoals je ziet geeft ministral-3 een mooi codeblok weer en andere LLM’s zijn daar minder op getraind, maar met deze aanpassingen kan je een codeblock afdwingen, de bijgewerkte prompt:
Geef alleen geldig JSON terug met maximaal 10 objecten.
Het format van het JSON schema moet zijn:
{
"detections": [
{
"label": string,
"confidence": float,
"bbox_norm": { "x1": float, "y1": float, "x2": float, "y2": float }
}
]
}
De variabelen zijn:
label = Het object wat je herkent op de foto.
confidence = De inschatting (of zekerheid) dat je denk wat het object is wat je hebt omschreven (float tussen 0.00 en 1.00).
x1, y1 = Genormaliseerde linker bovenhoek van de bounding box (float tussen 0.00 en 1.00).
x2, y2 = Genormaliseerde rechter onderhoek van de bounding box (float tussen 0.00 en 1.00).
Aandachtspunten:
1. Bounding boxes zijn relatief t.o.v. de volledige afbeelding.
2. Gebruik alleen genormaliseerde coördinaten (geen pixels).
3. Maak bounding boxes zo strak mogelijk om het object, niet om omgeving of achtergrond.
4. Geef geen bounding box die meer dan 60% van de afbeelding bedekt, tenzij het object echt zo groot is.
5. Benoem objecten van groot naar klein.
6. Gebruik korte objectnamen (1–2 woorden), geen attributen zoals adult/young/clothes_*.
7. Gebruik labels zoals: person, baby, stroller, accordion, bottle, shoe_1"
8. Benoem maximaal 10 objecten, mensen of dieren.
9. Het allerkleinste detail hoef je niet te benoemen.
10. Gebruik unieke labels waar mogelijk; als hetzelfde object meerdere keren voorkomt, voeg een suffix toe zoals: shoe_1, shoe_2.
11. Geef uitsluitend JSON terug, zonder extra tekst of uitleg.
12. Geef de JSON in een markdown codeblock met ```json en formatteer het netjes met inspringing.
13. Gebruik nette inspringing en geldige JSON.
Resultaat (glm-4.6v-flash):
{
"detections": [
{
"label": "person",
"confidence": 0.97,
"bbox_norm": { "x1": 0.46, "y1": 0.485, "x2": 0.86, "y2": 0.915 }
},
{
"label": "stroller",
"confidence": 0.96,
"bbox_norm": { "x1": 0.152, "y1": 0.535, "x2": 0.54, "y2": 0.952 }
},
{
"label": "accordion",
"confidence": 0.94,
"bbox_norm": { "x1": 0.57, "y1": 0.608, "x2": 0.867, "y2": 0.756 }
},
{
"label": "baby",
"confidence": 0.95,
"bbox_norm": { "x1": 0.25, "y1": 0.605, "x2": 0.43, "y2": 0.75 }
},
{
"label": "bottle",
"confidence": 0.92,
"bbox_norm": { "x1": 0.363, "y1": 0.635, "x2": 0.405, "y2": 0.658 }
},
{
"label": "shoe_1",
"confidence": 0.91,
"bbox_norm": { "x1": 0.702, "y1": 0.867, "x2": 0.754, "y2": 0.912 }
},
{
"label": "box",
"confidence": 0.89,
"bbox_norm": { "x1": 0.443, "y1": 0.908, "x2": 0.643, "y2": 0.971 }
}
]
}
Netjes en strak!
Ervaring en eindconclusie #
Zoals je ziet aan dit verhaal, was het een hele reis, maar het doel is bereikt: “Kan een LLM bouding boxes zetten zoals YOLO?” (JA!, dat kan zeker!)
Ik hoop dat je aan deze informatie (gedachtenexperiment) wat hebt gehad, en zo wat meer inzichten heb gekregen van idee naar eindresultaat!
Ranking
Voor LLM → bounding boxes → tooling zou ik nu zeggen:
- GLM-4.6V-flash
- Qwen3-VL-30B (verwacht: iets semantischer, iets minder strak)
- Qwen3-VL-8B
- Ministral-3-14B
YOLO wint nog steeds op pixel-perfect training, maar… niet op “bruikbare semantische detectie” zonder extra werk.

