Wie definiere ich die Marker für Watershed in OpenCV?

73

Ich schreibe für Android mit OpenCV. Ich segmentiere ein Bild ähnlich wie unten mit einer markergesteuerten Wasserscheide, ohne dass der Benutzer das Bild manuell markiert. Ich plane, die regionalen Maxima als Marker zu verwenden.

minMaxLoc()würde mir den Wert geben, aber wie kann ich ihn auf die Blobs beschränken, an denen ich interessiert bin? Kann ich die Ergebnisse von findContours()oder cvBlob-Blobs verwenden, um den ROI einzuschränken und Maxima auf jeden Blob anzuwenden?

Eingabebild

Tru
quelle

Antworten:

124

Zuallererst: Die Funktion minMaxLocfindet nur das globale Minimum und das globale Maximum für eine bestimmte Eingabe, sodass sie für die Bestimmung regionaler Minima und / oder regionaler Maxima meist unbrauchbar ist. Aber Ihre Idee ist richtig. Das Extrahieren von Markern basierend auf regionalen Minima / Maxima für die Durchführung einer Wasserscheidetransformation basierend auf Markern ist völlig in Ordnung. Lassen Sie mich versuchen zu klären, was die Watershed-Transformation ist und wie Sie die in OpenCV vorhandene Implementierung korrekt verwenden sollten.

Einige anständige Artikel, die sich mit Wassereinzugsgebieten befassen, beschreiben es ähnlich wie das Folgende (ich vermisse möglicherweise einige Details, wenn Sie sich nicht sicher sind: fragen Sie). Betrachten Sie die Oberfläche einer Region, die Sie kennen, sie enthält Täler und Gipfel (unter anderem Details, die für uns hier irrelevant sind). Angenommen, unter dieser Oberfläche haben Sie nur Wasser, farbiges Wasser. Machen Sie jetzt Löcher in jedes Tal Ihrer Oberfläche und dann füllt das Wasser den gesamten Bereich. Irgendwann treffen sich unterschiedlich gefärbte Gewässer, und wenn dies passiert, bauen Sie einen Damm so, dass sie sich nicht berühren. Am Ende haben Sie eine Sammlung von Dämmen, die die Wasserscheide ist, die das verschiedenfarbige Wasser trennt.

Wenn Sie nun zu viele Löcher in diese Oberfläche bohren, erhalten Sie zu viele Regionen: Über-Segmentierung. Wenn Sie zu wenig machen, erhalten Sie eine Untersegmentierung. Praktisch jedes Papier, das die Verwendung von Wassereinzugsgebieten vorschlägt, bietet Techniken, um diese Probleme für die Anwendung zu vermeiden, mit der sich das Papier befasst.

Ich habe das alles geschrieben (was möglicherweise für jeden, der weiß, was die Watershed-Transformation ist, zu naiv ist), weil es direkt darüber nachdenkt, wie Sie Watershed-Implementierungen verwenden sollten (was die derzeit akzeptierte Antwort auf völlig falsche Weise tut). Beginnen wir jetzt mit dem OpenCV-Beispiel unter Verwendung der Python-Bindungen.

Das in der Frage dargestellte Bild besteht aus vielen Objekten, die meist zu nah sind und sich in einigen Fällen überlappen. Der Nutzen der Wasserscheide besteht darin, diese Objekte korrekt zu trennen und nicht in einer einzigen Komponente zu gruppieren. Sie benötigen also mindestens eine Markierung für jedes Objekt und gute Markierungen für den Hintergrund. Binarisieren Sie beispielsweise zuerst das Eingabebild von Otsu und führen Sie eine morphologische Öffnung zum Entfernen kleiner Objekte durch. Das Ergebnis dieses Schritts ist unten im linken Bild dargestellt. Wenn Sie nun das Binärbild verwenden, ziehen Sie in Betracht, die Abstandstransformation auf das Bild anzuwenden.

Geben Sie hier die Bildbeschreibung ein Geben Sie hier die Bildbeschreibung ein

Mit dem Ergebnis der Entfernungstransformation können wir einen Schwellenwert berücksichtigen, sodass wir nur die Regionen berücksichtigen, die am weitesten vom Hintergrund entfernt sind (linkes Bild unten). Auf diese Weise können wir für jedes Objekt einen Marker erhalten, indem wir die verschiedenen Regionen nach dem früheren Schwellenwert kennzeichnen. Jetzt können wir auch den Rand einer erweiterten Version des linken Bildes oben betrachten, um unseren Marker zu erstellen. Die vollständige Markierung wird unten rechts angezeigt (einige Markierungen sind zu dunkel, um gesehen zu werden, aber jeder weiße Bereich im linken Bild wird im rechten Bild dargestellt).

Geben Sie hier die Bildbeschreibung ein Geben Sie hier die Bildbeschreibung ein

Dieser Marker, den wir hier haben, macht sehr viel Sinn. Jeder colored water == one markerwird beginnen, die Region zu füllen, und die Transformation der Wasserscheide wird Dämme bauen, um zu verhindern, dass die verschiedenen "Farben" verschmelzen. Wenn wir die Transformation durchführen, erhalten wir das Bild links. Wenn wir nur die Dämme betrachten, indem wir sie mit dem Originalbild zusammensetzen, erhalten wir das richtige Ergebnis.

Geben Sie hier die Bildbeschreibung ein Geben Sie hier die Bildbeschreibung ein

import sys
import cv2
import numpy
from scipy.ndimage import label

def segment_on_dt(a, img):
    border = cv2.dilate(img, None, iterations=5)
    border = border - cv2.erode(border, None)

    dt = cv2.distanceTransform(img, 2, 3)
    dt = ((dt - dt.min()) / (dt.max() - dt.min()) * 255).astype(numpy.uint8)
    _, dt = cv2.threshold(dt, 180, 255, cv2.THRESH_BINARY)
    lbl, ncc = label(dt)
    lbl = lbl * (255 / (ncc + 1))
    # Completing the markers now. 
    lbl[border == 255] = 255

    lbl = lbl.astype(numpy.int32)
    cv2.watershed(a, lbl)

    lbl[lbl == -1] = 0
    lbl = lbl.astype(numpy.uint8)
    return 255 - lbl


img = cv2.imread(sys.argv[1])

# Pre-processing.
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)    
_, img_bin = cv2.threshold(img_gray, 0, 255,
        cv2.THRESH_OTSU)
img_bin = cv2.morphologyEx(img_bin, cv2.MORPH_OPEN,
        numpy.ones((3, 3), dtype=int))

result = segment_on_dt(img, img_bin)
cv2.imwrite(sys.argv[2], result)

result[result != 255] = 0
result = cv2.dilate(result, None)
img[result == 255] = (0, 0, 255)
cv2.imwrite(sys.argv[3], img)
mmgp
quelle
1
Danke für das Tutorial. Sehr gut erklärt für uns, die mit dem Wasserscheidenalgorithmus nicht vertraut sind. Wie Sie bereits erwähnt haben, hängt die Anzahl der segmentierten Objekte hauptsächlich davon ab, wie viele Marker in den primären Schritten gefunden wurden. Hier scheint es, dass bei der Entfernungstransformation, gefolgt von der Schwellenwertbildung, einige Pillen als eine segmentiert wurden. Könnten wir die Ergebnisse verbessern, indem wir die Parameter des Schwellenwerts ändern?
Denis
1
es sollte lbl * (255/ (ncc + 1))sonst eine Kontur verloren gehen
Nikita
45

Ich möchte hier einen einfachen Code zur Verwendung der Wasserscheide erläutern. Ich verwende OpenCV-Python, aber ich hoffe, Sie werden keine Schwierigkeiten haben, es zu verstehen.

In diesem Code werde ich Wasserscheide als Werkzeug für die Extraktion von Vordergrund und Hintergrund verwenden. (Dieses Beispiel ist das Python-Gegenstück zum C ++ - Code im OpenCV-Kochbuch.) Dies ist ein einfacher Fall, um die Wasserscheide zu verstehen. Abgesehen davon können Sie die Anzahl der Objekte in diesem Bild mithilfe der Wasserscheide zählen. Dies wird eine leicht fortgeschrittene Version dieses Codes sein.

1 - Zuerst laden wir unser Bild, konvertieren es in Graustufen und setzen einen Schwellenwert für einen geeigneten Wert. Ich habe Otsus Binarisierung genommen , um den besten Schwellenwert zu finden.

import cv2
import numpy as np

img = cv2.imread('sofwatershed.jpg')
gray = cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)
ret,thresh = cv2.threshold(gray,0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU)

Unten ist das Ergebnis, das ich bekommen habe:

Geben Sie hier die Bildbeschreibung ein

(Auch dieses Ergebnis ist gut, da der Kontrast zwischen Vordergrund- und Hintergrundbildern groß ist.)

2 - Jetzt müssen wir den Marker erstellen. Marker ist das Bild mit der gleichen Größe wie das Originalbild, 32SC1 (32-Bit-Einzelkanal mit Vorzeichen).

Jetzt gibt es einige Bereiche im Originalbild, in denen Sie einfach sicher sind, dass dieser Teil zum Vordergrund gehört. Markieren Sie diesen Bereich mit 255 im Markierungsbild. Jetzt ist die Region, in der Sie sicher der Hintergrund sind, mit 128 markiert. Die Region, in der Sie sich nicht sicher sind, ist mit 0 markiert. Das werden wir als nächstes tun.

A - Vordergrundbereich : - Wir haben bereits ein Schwellenwertbild, in dem Pillen weiße Farbe haben. Wir erodieren sie ein wenig, so dass wir sicher sind, dass die verbleibende Region in den Vordergrund gehört.

fg = cv2.erode(thresh,None,iterations = 2)

fg :

Geben Sie hier die Bildbeschreibung ein

B - Hintergrundbereich : - Hier erweitern wir das Schwellenbild so, dass der Hintergrundbereich reduziert wird. Wir sind uns jedoch sicher, dass die verbleibende schwarze Region 100% Hintergrund ist. Wir setzen es auf 128.

bgt = cv2.dilate(thresh,None,iterations = 3)
ret,bg = cv2.threshold(bgt,1,128,1)

Jetzt bekommen wir bg wie folgt:

Geben Sie hier die Bildbeschreibung ein

C - Jetzt fügen wir sowohl fg als auch bg hinzu :

marker = cv2.add(fg,bg)

Folgendes bekommen wir:

Geben Sie hier die Bildbeschreibung ein

Jetzt können wir anhand des obigen Bildes klar verstehen, dass der weiße Bereich zu 100% im Vordergrund, der graue Bereich zu 100% im Hintergrund und der schwarze Bereich nicht sicher sind.

Dann konvertieren wir es in 32SC1:

marker32 = np.int32(marker)

3 - Schließlich wenden wir die Wasserscheide an und konvertieren das Ergebnis zurück in das uint8- Bild:

cv2.watershed(img,marker32)
m = cv2.convertScaleAbs(marker32)

m:

Geben Sie hier die Bildbeschreibung ein

4 - Wir legen den richtigen Schwellenwert fest, um die Maske zu erhalten und bitwise_andmit dem Eingabebild zu arbeiten:

ret,thresh = cv2.threshold(m,0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU)
res = cv2.bitwise_and(img,img,mask = thresh)

res:

Geben Sie hier die Bildbeschreibung ein

Ich hoffe es hilft!!!

ARCHE

Abid Rahman K.
quelle
9
Überprüfen Sie dies erneut, da die Verwendung der Wasserscheide völlig falsch ist.
mmgp
2
@mmgp: Vielleicht hast du recht. Dies ist nur eine Python-Version des grundlegenden C ++ - Codes, der im Kochbuch enthalten ist und zeigt, wie Markierungen usw. gesetzt werden. Ich dachte, es wäre ein schönes Beispiel für Erstsemester including me. Wie auch immer, deine Antwort ist großartig. Es füllt das aus, was in meiner Antwort fehlt.
Abid Rahman K
Benötige ich also ein 3-Farben-Bild als Marker oder reichen 2 Farben aus?
ed22
8

Vorwort

Ich stimme hauptsächlich zu, weil ich sowohl das Tutorial zur Wasserscheide in der OpenCV-Dokumentation (und im C ++ - Beispiel ) als auch die Antwort von mmgp oben als ziemlich verwirrend empfunden habe . Ich habe einen Wendepunkt-Ansatz mehrmals wiederholt, um letztendlich aus Frustration aufzugeben. Endlich wurde mir klar, dass ich diesen Ansatz zumindest ausprobieren und in Aktion sehen musste. Dies ist, was ich mir ausgedacht habe, nachdem ich alle Tutorials sortiert habe, auf die ich gestoßen bin.

Abgesehen davon, dass ich ein Anfänger im Bereich Computer Vision war, hatte der größte Teil meiner Probleme wahrscheinlich damit zu tun, dass ich die OpenCVSharp-Bibliothek anstelle von Python verwenden musste. In C # gibt es keine eingebauten Hochleistungs-Array-Operatoren wie in NumPy (obwohl mir klar ist, dass dies über IronPython portiert wurde), daher hatte ich große Probleme, diese Operationen in C # zu verstehen und zu implementieren. Außerdem verachte ich die Nuancen und Inkonsistenzen in den meisten dieser Funktionsaufrufe wirklich. OpenCVSharp ist eine der fragilsten Bibliotheken, mit denen ich je gearbeitet habe. Aber hey, es ist ein Hafen, also was habe ich erwartet? Das Beste ist jedoch, dass es kostenlos ist.

Lassen Sie uns ohne weiteres über meine OpenCVSharp-Implementierung der Wasserscheide sprechen und hoffentlich einige der wichtigsten Punkte der Implementierung der Wasserscheide im Allgemeinen klären.

Anwendung

Stellen Sie zunächst sicher, dass die Wasserscheide das ist, was Sie wollen, und verstehen Sie ihre Verwendung. Ich benutze gefärbte Zellplatten wie diese:

Geben Sie hier die Bildbeschreibung ein

Ich brauchte eine Weile, um herauszufinden, dass ich nicht nur einen Wassereinzugsgebietsanruf tätigen konnte, um jede Zelle auf dem Feld zu unterscheiden. Im Gegenteil, ich musste zuerst einen Teil des Feldes isolieren und dann diesen kleinen Teil als Wasserscheide bezeichnen. Ich habe meine Region of Interest (ROI) über eine Reihe von Filtern isoliert, die ich hier kurz erläutern werde:

Geben Sie hier die Bildbeschreibung ein

  1. Beginnen Sie mit dem Quellbild (links, zu Demonstrationszwecken zugeschnitten)
  2. Isolieren Sie den roten Kanal (links in der Mitte)
  3. Adaptive Schwelle anwenden (rechts in der Mitte)
  4. Finden Sie Konturen und entfernen Sie diese mit kleinen Flächen (rechts).

Sobald wir die Konturen gereinigt haben, die sich aus den oben genannten Schwellenwertoperationen ergeben, ist es Zeit, Kandidaten für die Wasserscheide zu finden. In meinem Fall habe ich einfach alle Konturen durchlaufen, die größer als ein bestimmter Bereich sind.

Code

Angenommen, wir haben diese Kontur aus dem obigen Feld als unseren ROI isoliert:

Geben Sie hier die Bildbeschreibung ein

Werfen wir einen Blick darauf, wie wir eine Wasserscheide codieren.

Wir beginnen mit einer leeren Matte und zeichnen nur die Kontur, die unseren ROI definiert:

var isolatedContour = new Mat(source.Size(), MatType.CV_8UC1, new Scalar(0, 0, 0));
Cv2.DrawContours(isolatedContour, new List<List<Point>> { contour }, -1, new Scalar(255, 255, 255), -1);

Damit der Aufruf zur Wasserscheide funktioniert, sind einige "Hinweise" zum ROI erforderlich. Wenn Sie ein absoluter Anfänger wie ich sind, empfehle ich Ihnen, die CMM-Wasserscheide-Seite zu lesen, um eine kurze Einführung zu erhalten. Es genügt zu sagen, dass wir links Hinweise zum ROI erstellen, indem wir rechts die Form erstellen:

Geben Sie hier die Bildbeschreibung ein

Um den weißen Teil (oder "Hintergrund") dieser "Hinweis" -Form zu erstellen, verwenden wir nur Dilatedie isolierte Form wie folgt:

var kernel = Cv2.GetStructuringElement(MorphShapes.Ellipse, new Size(2, 2));
var background = new Mat();
Cv2.Dilate(isolatedContour, background, kernel, iterations: 8);

Um den schwarzen Teil in der Mitte (oder im "Vordergrund") zu erstellen, verwenden wir eine Abstandstransformation gefolgt von einem Schwellenwert, der uns von der Form links zur Form rechts führt:

Geben Sie hier die Bildbeschreibung ein

Dies dauert einige Schritte, und Sie müssen möglicherweise mit der Untergrenze Ihres Schwellenwerts herumspielen, um Ergebnisse zu erzielen, die für Sie funktionieren:

var foreground = new Mat(source.Size(), MatType.CV_8UC1);
Cv2.DistanceTransform(isolatedContour, foreground, DistanceTypes.L2, DistanceMaskSize.Mask5);
Cv2.Normalize(foreground, foreground, 0, 1, NormTypes.MinMax); //Remember to normalize!

foreground.ConvertTo(foreground, MatType.CV_8UC1, 255, 0);
Cv2.Threshold(foreground, foreground, 150, 255, ThresholdTypes.Binary);

Dann subtrahieren wir diese beiden Matten, um das Endergebnis unserer "Hinweis" -Form zu erhalten:

var unknown = new Mat(); //this variable is also named "border" in some examples
Cv2.Subtract(background, foreground, unknown);

Wenn wir es Cv2.ImShow nicht wissen , würde es wieder so aussehen:

Geben Sie hier die Bildbeschreibung ein

Nett! Das war leicht für mich, meinen Kopf herumzuwickeln. Der nächste Teil hat mich jedoch ziemlich verwirrt. Schauen wir uns an, wie wir unseren "Hinweis" in etwas verwandeln, das die WatershedFunktion verwenden kann. Hierfür müssen wir verwenden ConnectedComponents, was im Grunde eine große Matrix von Pixeln ist, die aufgrund ihres Index gruppiert sind. Wenn wir beispielsweise eine Matte mit den Buchstaben "HI" hätten, ConnectedComponentskönnte diese Matrix zurückgegeben werden:

0 0 0 0 0 0 0 0 0
0 1 0 1 0 2 2 2 0
0 1 0 1 0 0 2 0 0 
0 1 1 1 0 0 2 0 0
0 1 0 1 0 0 2 0 0
0 1 0 1 0 2 2 2 0
0 0 0 0 0 0 0 0 0

0 ist also der Hintergrund, 1 ist der Buchstabe "H" und 2 ist der Buchstabe "I". (Wenn Sie an diesem Punkt angelangt sind und Ihre Matrix visualisieren möchten, empfehlen wir Ihnen , diese lehrreiche Antwort zu lesen .) So ConnectedComponentserstellen wir nun die Markierungen (oder Beschriftungen) für die Wasserscheide:

var labels = new Mat(); //also called "markers" in some examples
Cv2.ConnectedComponents(foreground, labels);
labels = labels + 1;

//this is a much more verbose port of numpy's: labels[unknown==255] = 0
for (int x = 0; x < labels.Width; x++)
{
    for (int y = 0; y < labels.Height; y++)
    {
        //You may be able to just send "int" in rather than "char" here:
        var labelPixel = (int)labels.At<char>(y, x);    //note: x and y are inexplicably 
        var borderPixel = (int)unknown.At<char>(y, x);  //and infuriatingly reversed

        if (borderPixel == 255)
            labels.Set(y, x, 0);
    }
}

Beachten Sie, dass für die Watershed-Funktion der Randbereich mit 0 markiert sein muss. Daher haben wir alle Randpixel im Beschriftungs- / Markierungsarray auf 0 gesetzt.

An diesem Punkt sollten wir alle bereit sein anzurufen Watershed. In meiner speziellen Anwendung ist es jedoch nützlich, nur einen kleinen Teil des gesamten Quellbilds während dieses Aufrufs zu visualisieren. Dies mag für Sie optional sein, aber ich maskiere zuerst nur einen kleinen Teil der Quelle, indem ich sie erweitere:

var mask = new Mat();
Cv2.Dilate(isolatedContour, mask, new Mat(), iterations: 20);
var sourceCrop = new Mat(source.Size(), source.Type(), new Scalar(0, 0, 0));
source.CopyTo(sourceCrop, mask);

Und dann machen Sie den magischen Ruf:

Cv2.Watershed(sourceCrop, labels);

Ergebnisse

Der obige WatershedAufruf wird labels an Ort und Stelle geändert . Sie müssen sich wieder an die Matrix erinnern, die sich daraus ergibt ConnectedComponents. Der Unterschied besteht darin, dass Wassereinzugsgebiete, die Dämme zwischen Wassereinzugsgebieten gefunden haben, in dieser Matrix als "-1" markiert werden. Wie das ConnectedComponentsErgebnis werden verschiedene Wassereinzugsgebiete auf ähnliche Weise mit inkrementierenden Zahlen markiert. Für meine Zwecke wollte ich diese in separaten Konturen speichern, deshalb habe ich diese Schleife erstellt, um sie aufzuteilen:

var watershedContours = new List<Tuple<int, List<Point>>>();

for (int x = 0; x < labels.Width; x++)
{
    for (int y = 0; y < labels.Height; y++)
    {
        var labelPixel = labels.At<Int32>(y, x); //note: x, y switched 

        var connected = watershedContours.Where(t => t.Item1 == labelPixel).FirstOrDefault();
        if (connected == null)
        {
            connected = new Tuple<int, List<Point>>(labelPixel, new List<Point>());
            watershedContours.Add(connected);
        }
        connected.Item2.Add(new Point(x, y));

        if (labelPixel == -1)
            sourceCrop.Set(y, x, new Vec3b(0, 255, 255));

    }
}

Dann wollte ich diese Konturen mit zufälligen Farben drucken, also habe ich die folgende Matte erstellt:

var watershed = new Mat(source.Size(), MatType.CV_8UC3, new Scalar(0, 0, 0));
foreach (var component in watershedContours)
{
    if (component.Item2.Count < (labels.Width * labels.Height) / 4 && component.Item1 >= 0)
    {
        var color = GetRandomColor();
        foreach (var point in component.Item2)
            watershed.Set(point.Y, point.X, color);
    }
}

Was ergibt, wenn gezeigt wird:

Geben Sie hier die Bildbeschreibung ein

Wenn wir auf dem Quellbild die Dämme zeichnen, die zuvor mit -1 markiert waren, erhalten wir Folgendes:

Geben Sie hier die Bildbeschreibung ein

Bearbeitungen:

Ich habe vergessen zu beachten: Stellen Sie sicher, dass Sie Ihre Matten aufräumen, nachdem Sie damit fertig sind. Sie bleiben im Speicher und OpenCVSharp zeigt möglicherweise eine unverständliche Fehlermeldung an. Ich sollte wirklich usingoben verwenden, ist aber auch mat.Release()eine Option.

Die obige Antwort von mmgp enthält auch die folgende Zeile: Dies dt = ((dt - dt.min()) / (dt.max() - dt.min()) * 255).astype(numpy.uint8)ist ein Histogramm- Streckschritt , der auf die Ergebnisse der Entfernungstransformation angewendet wird. Ich habe diesen Schritt aus mehreren Gründen weggelassen (hauptsächlich, weil ich nicht dachte, dass die Histogramme, die ich gesehen habe, zu eng waren), aber Ihr Kilometerstand kann variieren.

Daniel
quelle