particleeditor.py 12.7 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
# -*- coding: utf-8 -*-
"""
Created on Wed Jan 16 12:43:00 2019

@author: brandt
"""

"""
GEPARD - Gepard-Enabled PARticle Detection
Copyright (C) 2018  Lars Bittrich and Josef Brandt, Leibniz-Institut für 
Polymerforschung Dresden e. V. <bittrich-lars@ipfdd.de>    

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program, see COPYING.  
If not, see <https://www.gnu.org/licenses/>.
"""
Lars Bittrich's avatar
Lars Bittrich committed
27

28 29
import numpy as np
import cv2
JosefBrandt's avatar
 
JosefBrandt committed
30
from PyQt5 import QtWidgets, QtCore
Josef Brandt's avatar
Josef Brandt committed
31
from copy import deepcopy
32

JosefBrandt's avatar
JosefBrandt committed
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
from .particlePainter import ParticlePainter

def getContourStatsWithPixelScale(contours, pixelscale):
        ##characterize particle
        longellipse, shortellipse = np.nan, np.nan
        
        cnt = contours
        
        if cnt.shape[0] >= 5:       ##at least 5 points required for ellipse fitting...
            ellipse = cv2.fitEllipse(cnt)
            shortellipse, longellipse = ellipse[1]

        rect = cv2.minAreaRect(cnt)
        long, short = rect[1]
        if short>long:
            long, short = short, long
        
        area = cv2.contourArea(cnt)
    
        return long*pixelscale, short*pixelscale, longellipse*pixelscale, shortellipse*pixelscale, area*pixelscale**2

JosefBrandt's avatar
 
JosefBrandt committed
54

JosefBrandt's avatar
 
JosefBrandt committed
55 56 57
class ParticleContextMenu(QtWidgets.QMenu):
    combineParticlesSignal = QtCore.pyqtSignal(list, str)
    reassignParticlesSignal = QtCore.pyqtSignal(list, str)
JosefBrandt's avatar
 
JosefBrandt committed
58
    paintParticlesSignal = QtCore.pyqtSignal(list, str)
JosefBrandt's avatar
JosefBrandt committed
59 60
    deleteParticlesSignal = QtCore.pyqtSignal(list)
    
JosefBrandt's avatar
 
JosefBrandt committed
61
    def __init__(self, viewparent):
JosefBrandt's avatar
 
JosefBrandt committed
62
        super(ParticleContextMenu, self).__init__()
JosefBrandt's avatar
 
JosefBrandt committed
63 64 65
        self.viewparent = viewparent
        self.selectedParticleIndices = self.viewparent.selectedParticleIndices
        self.particleContainer = self.viewparent.dataset.particleContainer
JosefBrandt's avatar
 
JosefBrandt committed
66 67 68 69
        
    def executeAtScreenPos(self, screenPos):
        self.combineActs = []
        self.combineMenu = QtWidgets.QMenu("Combine Particles into")
JosefBrandt's avatar
 
JosefBrandt committed
70 71 72 73 74

        self.paintActs = []
        self.paintMenu = QtWidgets.QMenu("Paint Mode, merge into")

        selctedAssignments = []
JosefBrandt's avatar
 
JosefBrandt committed
75 76

        for particleIndex in self.selectedParticleIndices:
JosefBrandt's avatar
 
JosefBrandt committed
77 78 79 80
            try:
                assignment = self.particleContainer.getParticleAssignmentByIndex(particleIndex)
            except:
                return
JosefBrandt's avatar
 
JosefBrandt committed
81
            selctedAssignments.append(assignment)
JosefBrandt's avatar
 
JosefBrandt committed
82
        
JosefBrandt's avatar
 
JosefBrandt committed
83
        for assignment in np.unique(selctedAssignments):
JosefBrandt's avatar
 
JosefBrandt committed
84
            self.combineActs.append(self.combineMenu.addAction(assignment))
JosefBrandt's avatar
 
JosefBrandt committed
85
            self.paintActs.append(self.paintMenu.addAction(assignment))
JosefBrandt's avatar
 
JosefBrandt committed
86
        self.combineActs.append(self.combineMenu.addAction("other"))
JosefBrandt's avatar
 
JosefBrandt committed
87 88
        self.paintActs.append(self.paintMenu.addAction("other"))
        
JosefBrandt's avatar
 
JosefBrandt committed
89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104
        
        self.reassignActs = []
        self.reassignMenu = QtWidgets.QMenu("Reassign particle(s) into")
        for polymType in self.particleContainer.getUniquePolymers():
            self.reassignActs.append(self.reassignMenu.addAction(polymType))
        self.reassignActs.append(self.reassignMenu.addAction("other"))
        
        numParticles = len(self.selectedParticleIndices)
        if numParticles == 0:
            self.reassignMenu.setDisabled(True)
            self.combineMenu.setDisabled(True)
        elif numParticles == 1:
            self.combineMenu.setDisabled(True)
            
        self.addMenu(self.combineMenu)        
        self.addMenu(self.reassignMenu)
JosefBrandt's avatar
 
JosefBrandt committed
105
        self.addMenu(self.paintMenu)
JosefBrandt's avatar
JosefBrandt committed
106
        self.deleteAct = self.addAction("Delete particle(s)")
JosefBrandt's avatar
 
JosefBrandt committed
107 108 109
        
        action = self.exec_(screenPos)

JosefBrandt's avatar
 
JosefBrandt committed
110
        if action:
111
            newAssignment = self.validifyAssignment(action.text())
JosefBrandt's avatar
 
JosefBrandt committed
112 113 114 115 116 117 118

            if action in self.combineActs:
                self.combineParticlesSignal.emit(self.selectedParticleIndices, newAssignment)
            elif action in self.reassignActs:
                self.reassignParticlesSignal.emit(self.selectedParticleIndices, newAssignment)
            elif action in self.paintActs:
                self.paintParticlesSignal.emit(self.selectedParticleIndices, newAssignment)
JosefBrandt's avatar
JosefBrandt committed
119 120
            elif action == self.deleteAct:
                self.deleteParticlesSignal.emit(self.selectedParticleIndices)
121 122 123 124 125 126 127
    
    def validifyAssignment(self, assignment):
        if assignment == "other":
            assignment = self.getNewEntry()
        return assignment
    
    def getNewEntry(self):
JosefBrandt's avatar
 
JosefBrandt committed
128
        text, okClicked = QtWidgets.QInputDialog.getText(self.viewparent, "Custom assignment", "Enter new assignment")
129 130
        if okClicked and text != '':
            return text
JosefBrandt's avatar
 
JosefBrandt committed
131 132 133


class ParticleEditor(QtCore.QObject):
JosefBrandt's avatar
 
JosefBrandt committed
134
    particleAssignmentChanged = QtCore.pyqtSignal()
JosefBrandt's avatar
 
JosefBrandt committed
135
    def __init__(self, viewparent, particleContainer):
JosefBrandt's avatar
 
JosefBrandt committed
136
        super(ParticleEditor, self).__init__()
JosefBrandt's avatar
 
JosefBrandt committed
137
        self.particleContainer = particleContainer
JosefBrandt's avatar
 
JosefBrandt committed
138
        self.viewparent = viewparent #the assigned analysis widget
Hackmet's avatar
Hackmet committed
139 140 141
        self.backupFreq = 3     #save a backup every n actions
        self.neverBackedUp = True
        self.actionCounter = 0
JosefBrandt's avatar
 
JosefBrandt committed
142 143 144
        
        self.storedIndices = []
        self.storedAssignmend = None
JosefBrandt's avatar
 
JosefBrandt committed
145 146 147 148
    
    def connectToSignals(self, contextMenu):
        contextMenu.combineParticlesSignal.connect(self.combineParticles)
        contextMenu.reassignParticlesSignal.connect(self.reassignParticles)
JosefBrandt's avatar
 
JosefBrandt committed
149
        contextMenu.paintParticlesSignal.connect(self.paintParticles)
JosefBrandt's avatar
JosefBrandt committed
150
        contextMenu.deleteParticlesSignal.connect(self.deleteParticles)
JosefBrandt's avatar
 
JosefBrandt committed
151
    
152
    def createSafetyBackup(self):
Hackmet's avatar
Hackmet committed
153 154
        self.actionCounter += 1
        if self.actionCounter == self.backupFreq-1 or self.neverBackedUp:            
JosefBrandt's avatar
 
JosefBrandt committed
155
            backupname = self.viewparent.dataset.saveBackup()
Hackmet's avatar
Hackmet committed
156
            print('backing up as', backupname)
Hackmet's avatar
Hackmet committed
157
            self.neverBackedUp = False
JosefBrandt's avatar
 
JosefBrandt committed
158
            self.actionCounter = 0        
Hackmet's avatar
Hackmet committed
159
    
JosefBrandt's avatar
 
JosefBrandt committed
160
    @QtCore.pyqtSlot(list, str)
161 162 163 164
    def combineParticles(self, contourIndices, newAssignment):     
        self.createSafetyBackup()
        print(f'Combining particles {contourIndices} into {newAssignment}')
        contours = self.particleContainer.getParticleContoursByIndex(contourIndices)
JosefBrandt's avatar
JosefBrandt committed
165
        try:
Josef Brandt's avatar
Josef Brandt committed
166
            newContour = self.mergeContours(contours) 
JosefBrandt's avatar
JosefBrandt committed
167 168 169
        except NotConnectedContoursError:
            return
        
170
        pixelscale = self.viewparent.dataset.getPixelScale()
JosefBrandt's avatar
JosefBrandt committed
171
        stats = getContourStatsWithPixelScale(newContour, pixelscale)
172

JosefBrandt's avatar
JosefBrandt committed
173
        self.mergeParticlesInParticleContainerAndSampleView(contourIndices,newContour, stats, newAssignment)
174
    
JosefBrandt's avatar
 
JosefBrandt committed
175
    @QtCore.pyqtSlot(list, str)
176
    def reassignParticles(self, contourindices, newAssignment):
177
        self.createSafetyBackup()
178
        print(f'reassigning indices {contourindices} into {newAssignment}')
179
        for partIndex in contourindices:
180
            self.particleContainer.reassignParticleToAssignment(partIndex, newAssignment)
181

JosefBrandt's avatar
 
JosefBrandt committed
182
        self.particleAssignmentChanged.emit()
JosefBrandt's avatar
 
JosefBrandt committed
183 184 185
     
    @QtCore.pyqtSlot(list, str)
    def paintParticles(self, contourIndices, newAssignment):
JosefBrandt's avatar
JosefBrandt committed
186 187
        print(f'painting indices {contourIndices} into {newAssignment}')
        self.createSafetyBackup()
JosefBrandt's avatar
 
JosefBrandt committed
188 189
        self.storedIndices = contourIndices
        self.storedAssignmend = newAssignment
Josef Brandt's avatar
Josef Brandt committed
190
        
JosefBrandt's avatar
 
JosefBrandt committed
191
        contours = self.particleContainer.getParticleContoursByIndex(contourIndices)
192 193 194 195
#        topLeft = self.getTopLeft(contours)
#        img, self.xmin, self.ymin, self.padding = self.contoursToImg(contours, padding=0)
        img, xmin, ymin, self.padding = self.contoursToImg(contours, padding=0)
        topLeft = [ymin, xmin]
Josef Brandt's avatar
Josef Brandt committed
196 197
        self.particlePainter = ParticlePainter(self, img, topLeft)
        
JosefBrandt's avatar
 
JosefBrandt committed
198 199 200 201 202 203
        self.viewparent.normalSize()
        self.viewparent.particlePainter = self.particlePainter
        self.viewparent.scene().addItem(self.particlePainter)
        self.viewparent.update()
        
    def acceptPaintedResult(self):
JosefBrandt's avatar
JosefBrandt committed
204
        try:
205 206 207 208
            img = self.particlePainter.img
            xmin = self.particlePainter.topLeft[1]
            ymin = self.particlePainter.topLeft[0]
            newContour = self.imgToCnt(img, xmin, ymin, 0)
JosefBrandt's avatar
JosefBrandt committed
209 210 211 212 213
        except NotConnectedContoursError:
            self.storedIndices = []
            self.storedAssignmend = None
            self.destroyParticlePainter()
            return
JosefBrandt's avatar
 
JosefBrandt committed
214
        
JosefBrandt's avatar
JosefBrandt committed
215 216
        pixelscale = self.viewparent.dataset.getPixelScale()
        stats = getContourStatsWithPixelScale(newContour, pixelscale)
JosefBrandt's avatar
 
JosefBrandt committed
217
        
JosefBrandt's avatar
JosefBrandt committed
218
        self.mergeParticlesInParticleContainerAndSampleView(self.storedIndices, newContour, stats, self.storedAssignmend)
JosefBrandt's avatar
 
JosefBrandt committed
219 220 221 222 223 224 225 226 227 228 229
        
        self.storedIndices = []
        self.storedAssignmend = None
        self.destroyParticlePainter()
    
    def destroyParticlePainter(self):
        if self.particlePainter is not None:
            self.viewparent.particlePainter = None
            self.viewparent.scene().removeItem(self.particlePainter)
            self.viewparent.update()
            self.particlePainter = None
230 231
    
    def mergeContours(self, contours):
Josef Brandt's avatar
Josef Brandt committed
232 233 234 235 236 237
        img, xmin, ymin, padding = self.contoursToImg(contours)
        return self.imgToCnt(img, xmin, ymin, padding)
    
    def contoursToImg(self, contours, padding=2):
        contourCopy = deepcopy(contours)
        cnt = np.vstack(tuple(contourCopy))  #combine contous
238 239 240
        #draw contours
        xmin, xmax = cnt[:,0,:][:, 0].min(), cnt[:,0,:][:, 0].max()
        ymin, ymax = cnt[:,0,:][:, 1].min(), cnt[:,0,:][:, 1].max()        
Josef Brandt's avatar
Josef Brandt committed
241
        padding = padding   #pixel in each direction
242 243 244 245
        rangex = int(np.round((xmax-xmin)+2*padding))
        rangey = int(np.round((ymax-ymin)+2*padding))

        img = np.zeros((rangey, rangex))
Josef Brandt's avatar
Josef Brandt committed
246
        for curCnt in contourCopy:
247 248 249 250
            for i in range(len(curCnt)):
                curCnt[i][0][0] -= xmin-padding
                curCnt[i][0][1] -= ymin-padding
                
Josef Brandt's avatar
Josef Brandt committed
251 252
            cv2.drawContours(img, [curCnt], -1, 255, -1)
            cv2.drawContours(img, [curCnt], -1, 255, 1)
253 254
        
        img = np.uint8(cv2.morphologyEx(img, cv2.MORPH_CLOSE, np.ones((3, 3))))
Josef Brandt's avatar
Josef Brandt committed
255 256 257
        return img, xmin, ymin, padding
    
    def imgToCnt(self, img, xmin, ymin, padding):
258 259 260 261 262 263
        if cv2.__version__ > '3.5':
            contours, hierarchy = cv2.findContours(img, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_NONE)
        else:
            temp, contours, hierarchy = cv2.findContours(img, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_NONE)

        if len(contours)>1:
JosefBrandt's avatar
 
JosefBrandt committed
264
            QtWidgets.QMessageBox.critical(self.viewparent, 'ERROR!', 
265
                                           'Particle contours are not connected or have holes.\nThat is currently not supported!')
JosefBrandt's avatar
JosefBrandt committed
266
            raise NotConnectedContoursError
267 268 269 270 271
        
        newContour = contours[0]
        for i in range(len(newContour)):
            newContour[i][0][0] += xmin-padding
            newContour[i][0][1] += ymin-padding
272
            
273
        return newContour
JosefBrandt's avatar
JosefBrandt committed
274
    
Josef Brandt's avatar
Josef Brandt committed
275 276 277 278 279
    def getTopLeft(self, contours):
        cnt = np.vstack(tuple(contours))  #combine contous
        #draw contours
        xmin = cnt[:,0,:][:, 0].min()
        ymin= cnt[:,0,:][:, 1].min()
280
        return [ymin, xmin]
Josef Brandt's avatar
Josef Brandt committed
281
    
JosefBrandt's avatar
JosefBrandt committed
282 283 284
    def mergeParticlesInParticleContainerAndSampleView(self, indices, newContour, stats, assignment):
        self.viewparent.addParticleContourToIndex(newContour, len(self.viewparent.contourItems)-1)
        self.particleContainer.addMergedParticle(indices, newContour, stats, newAssignment=assignment)
285
        
JosefBrandt's avatar
JosefBrandt committed
286 287 288
        for ind in sorted(indices, reverse=True):
            self.viewparent.removeParticleContour(ind)
            self.particleContainer.removeParticle(ind)
289
        
JosefBrandt's avatar
JosefBrandt committed
290 291 292 293
        self.viewparent.resetContourIndices()
        self.particleContainer.resetParticleIndices()
        self.particleAssignmentChanged.emit()
        #TODO: INCLUDE SANITY CHECK!!!!!!!!!
294
    
JosefBrandt's avatar
JosefBrandt committed
295 296 297 298 299 300
    @QtCore.pyqtSlot(list)
    def deleteParticles(self, contourIndices):
        reply = QtWidgets.QMessageBox.question(self, f'About to delete {len(contourIndices)} particles.',
                                "Are you sure to permanantly delete these particles?",
                                QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No)
        if reply == QtWidgets.QMessageBox.Yes:
Hackmet's avatar
Hackmet committed
301

JosefBrandt's avatar
JosefBrandt committed
302 303 304 305 306 307 308
            for partIndex in sorted(contourIndices, reverse=True):
                self.viewparent.removeParticleContour(partIndex)
                self.viewparent.dataset.particleContainer.removeParticles([partIndex])
                
            self.viewparent.dataset.particleContainer.resetParticleIndices()
            self.viewparent.resetContourIndices()
            self.viewparent.analysiswidget.updateHistogramsAndContours()
JosefBrandt's avatar
JosefBrandt committed
309 310 311 312


class NotConnectedContoursError(Exception):
    pass
JosefBrandt's avatar
JosefBrandt committed
313
        
Hackmet's avatar
Hackmet committed
314