OpenCV – Einstieg in Bildverarbeitung und Objekterkennung

Zusammenfassung der Grundlagen: Bilder laden und anzeigen, Bilddaten verstehen, Bilder skalieren, Formen und Farben erkennen sowie störende Bereiche der Dobot-Arbeitsplatte ausblenden.

1. Was ist OpenCV?

OpenCV ist eine freie Programmbibliothek für Bildverarbeitung und Computer Vision. In Python wird sie über das Modul cv2 eingebunden.

Bild laden Bild aufbereiten Objekte trennen Formen und Farben bestimmen Koordinaten ausgeben

Bildverarbeitung ist häufig die Vorstufe der Bilderkennung. Dabei werden Bilddaten beispielsweise geglättet, segmentiert oder in eine Maske umgewandelt. Anschließend kann das Programm beschreiben, welche Objekte vorhanden sind.

2. Bild laden und anzeigen

from pathlib import Path
import cv2

ordner = Path(__file__).resolve().parent
bildpfad = ordner / "arbeitsplatte_mit_objekten.png"

bild = cv2.imread(str(bildpfad))

if bild is None:
    raise FileNotFoundError(f"Bild nicht gefunden: {bildpfad}")

cv2.imshow("Geladenes Bild", bild)
cv2.waitKey(0)
cv2.destroyAllWindows()
Befehl Bedeutung
cv2.imread(...) Lädt und decodiert die Bilddatei.
cv2.imshow(...) Zeigt ein Bild in einem OpenCV-Fenster.
cv2.waitKey(0) Wartet auf einen Tastendruck.
cv2.destroyAllWindows() Schließt alle OpenCV-Fenster.

3. Welche Daten enthält bild?

Ein geladenes Farbbild ist ein dreidimensionales NumPy-Array:

print(type(bild))
print(bild.shape)
print(bild.dtype)

Eine typische Ausgabe lautet:

<class 'numpy.ndarray'>
(662, 663, 3)
uint8
Angabe Bedeutung
662 Bildhöhe in Pixeln
663 Bildbreite in Pixeln
3 Drei Farbkanäle: Blau, Grün, Rot
uint8 Ganzzahlen von 0 bis 255

Ein einzelnes Pixel

pixel = bild[y, x]
print(pixel)

OpenCV verwendet die Reihenfolge BGR:

[Blau, Gruen, Rot]

schwarz = [0, 0, 0]
weiss   = [255, 255, 255]
rot     = [0, 0, 255]
gruen   = [0, 255, 0]
blau    = [255, 0, 0]
Wichtig: Ein Pixel wird mit bild[y, x] adressiert. Die Reihenfolge ist also zuerst die Zeile und danach die Spalte.

Warum erscheinen drei Punkte?

array([
    [[254, 253, 253], [253, 253, 253], ...],
    ...
])

Die Zeichen ... bedeuten, dass NumPy einen großen Teil der Werte nur in der Anzeige auslässt. Die Daten sind weiterhin vollständig im Array vorhanden.

Eine vollständige Bildzeile

bild[3]

liefert die vierte Bildzeile, weil Python bei null zu zählen beginnt.

4. Bilder verkleinern oder vergrößern

Skalierung in Prozent

prozent = 50
faktor = prozent / 100

kleines_bild = cv2.resize(
    bild,
    None,
    fx=faktor,
    fy=faktor,
    interpolation=cv2.INTER_AREA
)

Bei prozent = 50 werden Breite und Höhe jeweils halbiert. Für das Verkleinern ist cv2.INTER_AREA meist gut geeignet.

Wiederverwendbare Funktion

def bild_skalieren(bild, prozent):
    faktor = prozent / 100

    interpolation = (
        cv2.INTER_AREA
        if prozent < 100
        else cv2.INTER_LINEAR
    )

    return cv2.resize(
        bild,
        None,
        fx=faktor,
        fy=faktor,
        interpolation=interpolation
    )

anzeige = bild_skalieren(bild, 50)
Werden die eigentlichen Bilddaten verkleinert, ändern sich auch alle Pixelkoordinaten. Für genaue Messungen sollte das Originalbild ausgewertet und nur die Anzeige verkleinert werden.

5. Formen erkennen

Nach der Segmentierung werden mit cv2.findContours() die äußeren Umrisse der Objekte bestimmt.

konturen, _ = cv2.findContours(
    maske,
    cv2.RETR_EXTERNAL,
    cv2.CHAIN_APPROX_SIMPLE
)

Für jede Kontur können folgende Daten berechnet werden:

Funktion Ergebnis
cv2.contourArea(kontur) Fläche in Pixel²
cv2.arcLength(kontur, True) Umfang in Pixeln
cv2.boundingRect(kontur) x, y, Breite und Höhe
cv2.moments(kontur) Mittelpunkt der Kontur
cv2.approxPolyDP(...) Vereinfachte Kontur und Eckenzahl

Grundidee der Formerkennung

if kreisfoermigkeit > 0.84:
    form = "Kreis"

elif anzahl_ecken == 3:
    form = "Dreieck"

elif anzahl_ecken == 4:
    seitenverhaeltnis = breite / float(hoehe)

    if 0.90 <= seitenverhaeltnis <= 1.10:
        form = "Quadrat"
    else:
        form = "Rechteck"

else:
    form = "Unbekannt"
Für Kreise ist die reine Eckenzahl ungeeignet. Deshalb wird zusätzlich die Kreisförmigkeit aus Fläche und Umfang verwendet.

6. Farben erkennen

Für die Farberkennung eignet sich der HSV-Farbraum besser als BGR, weil Farbton, Sättigung und Helligkeit getrennt vorliegen.

hsv = cv2.cvtColor(
    bild,
    cv2.COLOR_BGR2HSV
)

Farbige Flächen auswählen

maske = cv2.inRange(
    hsv,
    np.array([0, 80, 50]),
    np.array([179, 255, 255])
)

Damit werden deutlich gesättigte Farben weiß markiert. Weißer Hintergrund, schwarze Linien und Rasterlöcher bleiben überwiegend schwarz.

Farbnamen aus dem Farbton bestimmen

def farbe_bestimmen(farbton):
    if farbton < 10 or farbton >= 170:
        return "Rot"
    if farbton < 40:
        return "Gelb"
    if farbton < 85:
        return "Gruen"
    if farbton < 130:
        return "Blau"
    if farbton < 165:
        return "Violett"
    return "Unbekannt"

7. Arbeitsplatte ausblenden

Leere Dobot-Arbeitsplatte
Leere Arbeitsplatte als mögliches Referenzbild.
Dobot-Arbeitsplatte mit farbigen Formen
Arbeitsplatte mit farbigen Formen.

Methode A: Farbmaske

Für die gezeichneten farbigen Formen ist eine HSV-Maske besonders einfach. Kleine rote Striche des Arbeitsbereiches können über eine Mindestfläche verworfen werden.

for kontur in konturen:
    flaeche = cv2.contourArea(kontur)

    if flaeche < 2000:
        continue

Methode B: Leeres Referenzbild

Bei einer realen, fest montierten Kamera kann ein Foto der leeren Platte vom aktuellen Bild abgezogen werden.

differenz = cv2.absdiff(
    bild_mit_objekten,
    bild_leer
)

grau = cv2.cvtColor(
    differenz,
    cv2.COLOR_BGR2GRAY
)

_, maske = cv2.threshold(
    grau,
    30,
    255,
    cv2.THRESH_BINARY
)

Dieses Verfahren kann auch schwarze, weiße oder graue Objekte erkennen. Kamera, Platte und Beleuchtung sollten dabei möglichst unverändert bleiben.

8. Bereiche von der Erkennung ausschließen

Eine Bereichsmaske legt fest, welche Teile des Bildes ausgewertet werden. Weiße Bereiche sind erlaubt, schwarze Bereiche werden ignoriert.

Gesamtes Bild zunächst erlauben

hoehe, breite = bild.shape[:2]

bereichsmaske = np.full(
    (hoehe, breite),
    255,
    dtype=np.uint8
)

Dobot-Standplatz ausschließen

cv2.rectangle(
    bereichsmaske,
    (245, 265),
    (415, 435),
    0,
    -1
)

Mit der Farbmaske verbinden

maske = cv2.bitwise_and(
    farbmaske,
    bereichsmaske
)

Nur einen kreisförmigen Arbeitsbereich erlauben

bereichsmaske = np.zeros(
    (hoehe, breite),
    dtype=np.uint8
)

cv2.circle(
    bereichsmaske,
    (330, 330),
    300,
    255,
    -1
)
Die Bereichsmaske sollte vor der Konturerkennung angewendet werden. Dadurch gelangen ausgeschlossene Bereiche gar nicht erst in die Objektliste.

9. Kompaktes Gesamtbeispiel

Dieses Programm lädt das Bild, erzeugt eine Farbmaske, schließt einen rechteckigen Bereich aus, erkennt Konturen und schreibt die Ergebnisse in die Thonny-Shell.

from pathlib import Path
import math

import cv2
import numpy as np


def farbe_bestimmen(farbton):
    if farbton < 10 or farbton >= 170:
        return "Rot"
    if farbton < 40:
        return "Gelb"
    if farbton < 85:
        return "Gruen"
    if farbton < 130:
        return "Blau"
    if farbton < 165:
        return "Violett"
    return "Unbekannt"


ordner = Path(__file__).resolve().parent
bildpfad = ordner / "arbeitsplatte_mit_objekten.png"

bild = cv2.imread(str(bildpfad))

if bild is None:
    raise FileNotFoundError(f"Bild nicht gefunden: {bildpfad}")

ergebnis = bild.copy()
hsv = cv2.cvtColor(bild, cv2.COLOR_BGR2HSV)

farbmaske = cv2.inRange(
    hsv,
    np.array([0, 80, 50]),
    np.array([179, 255, 255])
)

hoehe, breite = bild.shape[:2]

bereichsmaske = np.full(
    (hoehe, breite),
    255,
    dtype=np.uint8
)

# Beispiel: Dobot-Standplatz ausschließen
cv2.rectangle(
    bereichsmaske,
    (245, 265),
    (415, 435),
    0,
    -1
)

maske = cv2.bitwise_and(
    farbmaske,
    bereichsmaske
)

kernel = cv2.getStructuringElement(
    cv2.MORPH_ELLIPSE,
    (5, 5)
)

maske = cv2.morphologyEx(
    maske,
    cv2.MORPH_OPEN,
    kernel
)

maske = cv2.morphologyEx(
    maske,
    cv2.MORPH_CLOSE,
    kernel
)

konturen, _ = cv2.findContours(
    maske,
    cv2.RETR_EXTERNAL,
    cv2.CHAIN_APPROX_SIMPLE
)

objekte = []

for kontur in konturen:
    flaeche = cv2.contourArea(kontur)

    if flaeche < 2000:
        continue

    umfang = cv2.arcLength(kontur, True)

    if umfang == 0:
        continue

    naeherung = cv2.approxPolyDP(
        kontur,
        0.02 * umfang,
        True
    )

    ecken = len(naeherung)
    x, y, w, h = cv2.boundingRect(kontur)

    momente = cv2.moments(kontur)

    if momente["m00"] != 0:
        mitte_x = int(momente["m10"] / momente["m00"])
        mitte_y = int(momente["m01"] / momente["m00"])
    else:
        mitte_x = x + w // 2
        mitte_y = y + h // 2

    kreisfoermigkeit = (
        4 * math.pi * flaeche / (umfang * umfang)
    )

    if kreisfoermigkeit > 0.84:
        form = "Kreis"
    elif ecken == 3:
        form = "Dreieck"
    elif ecken == 4:
        verhaeltnis = w / float(h)
        form = (
            "Quadrat"
            if 0.90 <= verhaeltnis <= 1.10
            else "Rechteck"
        )
    else:
        form = "Unbekannt"

    objektmaske = np.zeros(
        maske.shape,
        dtype=np.uint8
    )

    cv2.drawContours(
        objektmaske,
        [kontur],
        -1,
        255,
        -1
    )

    mittlere_hsv_farbe = cv2.mean(
        hsv,
        mask=objektmaske
    )

    farbe = farbe_bestimmen(
        mittlere_hsv_farbe[0]
    )

    objekte.append({
        "kontur": kontur,
        "farbe": farbe,
        "form": form,
        "mitte_x": mitte_x,
        "mitte_y": mitte_y,
        "breite": w,
        "hoehe": h,
        "flaeche": flaeche,
        "umfang": umfang
    })

objekte.sort(
    key=lambda objekt: objekt["mitte_x"]
)

print()
print("AUSWERTUNG DER ARBEITSPLATTE")
print("=" * 92)
print(
    f'{"Nr.":>3} '
    f'{"Farbe":<10} '
    f'{"Form":<12} '
    f'{"Mitte x":>8} '
    f'{"Mitte y":>8} '
    f'{"Breite":>8} '
    f'{"Hoehe":>8} '
    f'{"Flaeche":>10}'
)
print("-" * 92)

for nummer, objekt in enumerate(objekte, start=1):
    print(
        f'{nummer:>3} '
        f'{objekt["farbe"]:<10} '
        f'{objekt["form"]:<12} '
        f'{objekt["mitte_x"]:>8} '
        f'{objekt["mitte_y"]:>8} '
        f'{objekt["breite"]:>8} '
        f'{objekt["hoehe"]:>8} '
        f'{objekt["flaeche"]:>10.1f}'
    )

print("-" * 92)
print(f"Anzahl erkannter Objekte: {len(objekte)}")

cv2.imshow("Maske", maske)
cv2.imshow("Original", bild)
cv2.waitKey(0)
cv2.destroyAllWindows()

10. Sinnvolle nächste Schritte

  1. Pixelmittelpunkte den Lochkoordinaten der Arbeitsplatte zuordnen.
  2. Pixelabstände in Millimeter umrechnen.
  3. Perspektivische Verzerrungen der Kamera korrigieren.
  4. Nur Objekte innerhalb der Dobot-Reichweite berücksichtigen.
  5. Erkannte Objektkoordinaten in Dobot-Koordinaten umrechnen.
  6. Später reale Kamerabilder statt gezeichneter Testbilder verwenden.
Ziel des bisherigen Aufbaus: Aus einem Bild wird eine strukturierte Objektliste mit Farbe, Form, Mittelpunkt, Größe, Fläche und Umfang.