75 votes

Comment définir les marqueurs pour les bassins versants dans OpenCV ?

J'écris pour Android avec OpenCV. Je segmente une image similaire à celle ci-dessous en utilisant un bassin versant contrôlé par des marqueurs, sans que l'utilisateur ne marque manuellement l'image. Je prévois d'utiliser les maxima régionaux comme marqueurs.

minMaxLoc() me donnerait la valeur, mais comment puis-je la restreindre aux blobs, ce qui m'intéresse ? Puis-je utiliser les résultats de findContours() ou des blobs cvBlob pour restreindre le ROI et appliquer des maxima à chaque blob ?

input image

127voto

mmgp Points 9153

Tout d'abord, la fonction minMaxLoc ne trouve que le minimum et le maximum global pour une entrée donnée, il est donc pratiquement inutile pour déterminer les minima et/ou maxima régionaux. Mais votre idée est juste, extraire des marqueurs basés sur les minima/maxima régionaux pour effectuer une transformation de la ligne de partage des eaux basée sur les marqueurs est tout à fait correct. Je vais essayer de clarifier ce qu'est la transformation de la ligne de partage des eaux et comment utiliser correctement l'implémentation présente dans OpenCV.

Une bonne partie des articles qui traitent des bassins versants le décrivent de manière similaire à ce qui suit (il se peut que j'oublie certains détails, si vous n'êtes pas sûr, demandez). Considérez la surface d'une région que vous connaissez, elle contient des vallées et des pics (parmi d'autres détails qui ne sont pas pertinents pour nous ici). Supposez que sous cette surface, vous n'ayez que de l'eau, de l'eau colorée. Maintenant, faites des trous dans chaque vallée de votre surface et ensuite l'eau commence à remplir toute la zone. À un moment donné, des eaux de couleurs différentes vont se rencontrer, et lorsque cela se produit, vous construisez un barrage de telle sorte qu'elles ne se touchent pas. Au final, vous avez une collection de barrages, qui est la ligne de partage des eaux séparant toutes les eaux de couleurs différentes.

Maintenant, si vous faites trop de trous dans cette surface, vous vous retrouvez avec trop de régions : sur-segmentation. Si vous en faites trop peu, vous obtenez une sous-segmentation. Ainsi, pratiquement tous les articles qui suggèrent l'utilisation d'une ligne de partage des eaux présentent en fait des techniques permettant d'éviter ces problèmes pour l'application traitée par l'article.

J'ai écrit tout cela (qui est peut-être trop naïf pour quiconque sait ce qu'est la transformation de bassin versant) parce que cela reflète directement la façon dont vous devriez utiliser les implémentations de bassin versant (ce que la réponse acceptée actuellement fait d'une manière complètement erronée). Commençons maintenant par l'exemple OpenCV, en utilisant les liaisons Python.

L'image présentée dans la question est composée de nombreux objets qui sont pour la plupart trop proches et qui, dans certains cas, se chevauchent. L'utilité des bassins ici est de séparer correctement ces objets, et non de les regrouper en un seul composant. Vous avez donc besoin d'au moins un marqueur pour chaque objet et de bons marqueurs pour l'arrière-plan. A titre d'exemple, binarisez d'abord l'image d'entrée par Otsu et effectuez une ouverture morphologique pour supprimer les petits objets. Le résultat de cette étape est montré ci-dessous dans l'image de gauche. Maintenant, avec l'image binaire, pensez à lui appliquer la transformation de distance, résultat à droite.

enter image description hereenter image description here

Avec le résultat de la transformation de distance, nous pouvons considérer un certain seuil de façon à ne considérer que les régions les plus éloignées du fond (image de gauche ci-dessous). En faisant cela, nous pouvons obtenir un marqueur pour chaque objet en étiquetant les différentes régions après le seuil précédent. Maintenant, nous pouvons également considérer le bord d'une version dilatée de l'image de gauche ci-dessus pour composer notre marqueur. Le marqueur complet est montré ci-dessous à droite (certains marqueurs sont trop sombres pour être vus, mais chaque région blanche de l'image de gauche est représentée sur l'image de droite).

enter image description hereenter image description here

Le marqueur que nous avons ici a beaucoup de sens. Chaque colored water == one marker va commencer à remplir la région, et la transformation en bassin versant va construire des barrages pour empêcher que les différentes "couleurs" fusionnent. Si on fait la transformation, on obtient l'image de gauche. En considérant uniquement les barrages en les composant avec l'image originale, nous obtenons le résultat à droite.

enter image description hereenter image description here

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)

1 votes

Merci pour le tutoriel. Très bien expliqué pour nous qui ne sommes pas familiers avec l'algorithme des bassins versants. Comme vous l'avez mentionné, le nombre d'objets segmentés dépendra principalement du nombre de marqueurs trouvés lors des étapes primaires et ici, il semble que la transformation de distance suivie d'un seuillage ait laissé quelques pilules segmentées comme une seule. Aurions-nous pu améliorer les résultats en modifiant les paramètres du seuillage ?

1 votes

Il devrait l'être lbl * (255/ (ncc + 1)) sinon un contour est perdu

45voto

Abid Rahman K Points 18045

Je voudrais expliquer ici un code simple sur la façon d'utiliser les bassins versants. J'utilise OpenCV-Python, mais j'espère que vous n'aurez aucune difficulté à comprendre.

Dans ce code, je vais utiliser le bassin versant comme un outil pour extraction de l'avant-plan et de l'arrière-plan. (Cet exemple est la contrepartie en python du code C++ du livre de recettes OpenCV). Il s'agit d'un cas simple pour comprendre la ligne de partage des eaux. En dehors de cela, vous pouvez utiliser la ligne de partage des eaux pour compter le nombre d'objets dans cette image. Ce sera une version légèrement avancée de ce code.

1 - Tout d'abord, nous chargeons notre image, la convertissons en niveaux de gris, et la seuillons avec une valeur appropriée. J'ai pris La binarisation d'Otsu afin de trouver la meilleure valeur seuil.

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)

Voici le résultat que j'ai obtenu :

enter image description here

( même ce résultat est bon, car il y a un grand contraste entre les images de premier plan et d'arrière-plan)

2 - Maintenant nous devons créer le marqueur. Le marqueur est l'image de la même taille que l'image originale qui est 32SC1 (32 bit signed single channel).

Maintenant, il y aura quelques régions dans l'image originale où vous êtes simplement sûr que cette partie appartient au premier plan. Marquez ces régions avec 255 dans l'image de marquage. Maintenant, la région dont vous êtes sûr qu'elle est l'arrière-plan est marquée avec 128. La région dont vous n'êtes pas sûr est marquée avec 0. C'est ce que nous allons faire maintenant.

A - Région de premier plan :- Nous avons déjà obtenu une image seuil où les pilules sont de couleur blanche. Nous les érodons un peu, afin d'être sûrs que la région restante appartient au premier plan.

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

fg :

enter image description here

B - Région de fond :- Ici, nous dilatons l'image seuillée afin de réduire la région d'arrière-plan. Mais nous sommes sûrs que la région noire restante est un fond à 100%. Nous le réglons à 128.

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

Maintenant, nous obtenons bg comme suit :

enter image description here

C - Maintenant nous ajoutons à la fois fg et bg :

marker = cv2.add(fg,bg)

Voici ce que nous obtenons :

enter image description here

Maintenant, nous pouvons clairement comprendre de l'image ci-dessus, que la région blanche est 100% avant-plan, la région grise est 100% arrière-plan, et la région noire nous ne sommes pas sûrs.

Puis nous le convertissons en 32SC1 :

marker32 = np.int32(marker)

3 - Enfin, nous appliquer la ligne de partage des eaux et reconvertir le résultat en uint8 image :

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

m :

enter image description here

4 - Nous le seuil correctement pour obtenir le masque et exécuter bitwise_and avec l'image d'entrée :

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

res :

enter image description here

J'espère que cela vous aidera ! !!

ARK

9 votes

Pensez à revérifier ceci, car cela montre une utilisation complètement incorrecte du bassin versant.

2 votes

@mmgp : vous avez peut-être raison. C'est juste une version python du code C++ de base donné dans le livre de recettes, qui montre comment définir les marqueurs, etc. J'ai pensé que ce serait un bon exemple pour les débutants, including me . Quoi qu'il en soit, votre réponse est excellente. Elle comble ce qui manque dans ma réponse.

0 votes

Alors, est-ce que j'ai besoin d'une image en 3 couleurs comme marqueur ou 2 couleurs suffisent ?

8voto

Doc Points 4365

Avant-propos

J'interviens surtout parce que j'ai trouvé à la fois le tutoriel sur les bassins versants dans la documentation d'OpenCV (et Exemple C++ ) ainsi que Réponse de mmgp ci-dessus pour être assez confus. J'ai revisité plusieurs fois l'approche d'un bassin versant pour finalement abandonner par frustration. J'ai finalement réalisé que je devais au moins essayer cette approche et la voir en action. Voici ce que j'ai trouvé après avoir trié tous les tutoriels que j'ai rencontrés.

Outre le fait que je suis un novice en matière de vision par ordinateur, la plupart de mes difficultés étaient probablement liées à l'obligation d'utiliser la bibliothèque OpenCVSharp plutôt que Python. Le C# n'a pas d'opérateurs de tableaux puissants intégrés comme ceux que l'on trouve dans NumPy (bien que je réalise que cela a été porté via IronPython), donc j'ai eu beaucoup de mal à comprendre et à implémenter ces opérations en C#. En outre, pour mémoire, je méprise vraiment les nuances et les incohérences de la plupart de ces appels de fonction. OpenCVSharp est l'une des bibliothèques les plus fragiles avec lesquelles j'ai jamais travaillé. Mais bon, c'est un portage, alors à quoi je m'attendais ? Le meilleur de tous, cependant -- c'est gratuit.

Sans plus attendre, parlons de mon implémentation OpenCVSharp de la ligne de partage des eaux, et espérons-le, clarifions certains des points les plus épineux de l'implémentation de la ligne de partage des eaux en général.

Application

Tout d'abord, assurez-vous que le bassin versant correspond à ce que vous voulez et comprenez son utilisation. J'utilise des plaques cellulaires colorées, comme celle-ci :

enter image description here

Il m'a fallu un bon moment pour comprendre que je ne pouvais pas passer un seul coup de fil pour différencier toutes les cellules du champ. Au contraire, je devais d'abord isoler une partie du champ, puis appeler la ligne de partage des eaux sur cette petite partie. J'ai isolé ma région d'intérêt (ROI) via un certain nombre de filtres, que je vais expliquer brièvement ici :

enter image description here

  1. Commencez par l'image source (à gauche, recadrée à des fins de démonstration)
  2. Isoler le canal rouge (milieu gauche)
  3. Appliquer un seuil adaptatif (milieu droit)
  4. Trouver les contours puis éliminer ceux qui présentent de petites surfaces (à droite)

Une fois que nous avons nettoyé les contours résultant des opérations de seuillage ci-dessus, il est temps de trouver des candidats pour les bassins versants. Dans mon cas, j'ai simplement itéré sur tous les contours dépassant une certaine surface.

Code

Disons que nous avons isolé ce contour du champ ci-dessus comme notre ROI :

enter image description here

Voyons comment nous allons coder un bassin versant.

Nous allons commencer par un tapis vierge et dessiner uniquement le contour définissant notre ROI :

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);

Pour que l'appel du bassin versant fonctionne, il a besoin de quelques "indices" sur le ROI. Si, comme moi, vous êtes un débutant complet, je vous recommande de consulter la page Page du bassin versant de la CMM pour une introduction rapide. Il suffit de dire que nous allons créer des indices sur le ROI de gauche en créant la forme de droite :

enter image description here

Pour créer la partie blanche (ou " arrière-plan ") de cette forme " indice ", nous allons juste Dilate la forme isolée comme ceci :

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

Pour créer la partie noire au milieu (ou "premier plan"), nous allons utiliser une transformation de distance suivie d'un seuil, qui nous fait passer de la forme de gauche à celle de droite :

enter image description here

Cela prend quelques étapes, et vous devrez peut-être jouer avec la limite inférieure de votre seuil pour obtenir des résultats qui vous conviennent :

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);

Ensuite, nous soustrairons ces deux tapis pour obtenir le résultat final de notre forme "indice" :

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

Encore une fois, si nous Cv2.ImShow inconnu cela ressemblerait à ceci :

enter image description here

Joli ! C'était facile pour moi de me faire à l'idée. La partie suivante, par contre, m'a laissé assez perplexe. Voyons comment transformer notre "indice" en quelque chose de l'ordre du Watershed peut utiliser. Pour cela, nous devons utiliser ConnectedComponents qui est en fait une grande matrice de pixels regroupés en fonction de leur indice. Par exemple, si nous avions un tapis avec les lettres "HI", ConnectedComponents pourrait retourner cette matrice :

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

Ainsi, 0 est le fond, 1 est la lettre "H", et 2 est la lettre "I". (Si vous arrivez à ce stade et que vous souhaitez visualiser votre matrice, je vous recommande de consulter le site suivant cette réponse instructive .) Maintenant, voici comment nous allons utiliser ConnectedComponents pour créer les marqueurs (ou étiquettes) du bassin versant :

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);
    }
}

Notez que la fonction Watershed exige que la zone de bordure soit marquée par 0. Nous avons donc défini tous les pixels de bordure à 0 dans le tableau des étiquettes/marqueurs.

A ce stade, nous devrions être prêts à appeler Watershed . Cependant, dans mon application particulière, il est utile de visualiser une petite partie de l'image source entière pendant cet appel. Cela peut être facultatif pour vous, mais je commence par masquer une petite partie de la source en la dilatant :

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);

Et ensuite faire l'appel magique :

Cv2.Watershed(sourceCrop, labels);

Résultats

Ce qui précède Watershed L'appel modifiera labels en place . Vous devrez revenir à la mémoire de la matrice résultant de ConnectedComponents . La différence ici est que si le bassin versant a trouvé des barrages entre les bassins versants, ils seront marqués comme "-1" dans cette matrice. Comme le ConnectedComponents Par conséquent, les différents bassins versants seront marqués d'une manière similaire par des nombres croissants. Pour mes besoins, je voulais les stocker dans des contours séparés, j'ai donc créé cette boucle pour les séparer :

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));

    }
}

Ensuite, je voulais imprimer ces contours avec des couleurs aléatoires, j'ai donc créé le tapis suivant :

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);
    }
}

Ce qui donne le résultat suivant lorsqu'il est montré :

enter image description here

Si nous dessinons sur l'image source les barrages qui ont été marqués par un -1 plus tôt, nous obtenons ceci :

enter image description here

Edits :

J'ai oublié de noter : assurez-vous de nettoyer vos tapis après les avoir utilisés. Ils vont rester en mémoire et OpenCVSharp peut présenter un message d'erreur incompréhensible. Je devrais vraiment utiliser using ci-dessus, mais mat.Release() est également une option.

En outre, la réponse de mmgp ci-dessus comprend cette ligne : dt = ((dt - dt.min()) / (dt.max() - dt.min()) * 255).astype(numpy.uint8) qui est une étape d'étirement de l'histogramme appliquée aux résultats de la transformation de distance. J'ai omis cette étape pour un certain nombre de raisons (principalement parce que je ne pensais pas que les histogrammes que je voyais étaient trop étroits pour commencer), mais vos habitudes peuvent varier.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X