particleeditor.py 12.5 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)
Josef Brandt's avatar
Josef Brandt committed
192 193 194 195 196
        topLeft = self.getTopLeft(contours)
        img, self.xmin, self.ymin, self.padding = self.contoursToImg(contours)
        
        self.particlePainter = ParticlePainter(self, img, topLeft)
        
JosefBrandt's avatar
 
JosefBrandt committed
197 198 199 200 201 202
        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
203
        try:
Josef Brandt's avatar
Josef Brandt committed
204
            newContour = self.mergeContours(self.particlePainter.contours)
JosefBrandt's avatar
JosefBrandt committed
205 206 207 208 209
        except NotConnectedContoursError:
            self.storedIndices = []
            self.storedAssignmend = None
            self.destroyParticlePainter()
            return
JosefBrandt's avatar
 
JosefBrandt committed
210
        
JosefBrandt's avatar
JosefBrandt committed
211 212
        pixelscale = self.viewparent.dataset.getPixelScale()
        stats = getContourStatsWithPixelScale(newContour, pixelscale)
JosefBrandt's avatar
 
JosefBrandt committed
213
        
JosefBrandt's avatar
JosefBrandt committed
214
        self.mergeParticlesInParticleContainerAndSampleView(self.storedIndices, newContour, stats, self.storedAssignmend)
JosefBrandt's avatar
 
JosefBrandt committed
215 216 217 218 219 220 221 222 223 224 225
        
        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
226 227
    
    def mergeContours(self, contours):
Josef Brandt's avatar
Josef Brandt committed
228 229 230 231 232 233
        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
234 235 236
        #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
237
        padding = padding   #pixel in each direction
238 239 240 241
        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
242
        for curCnt in contourCopy:
243 244 245 246
            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
247 248
            cv2.drawContours(img, [curCnt], -1, 255, -1)
            cv2.drawContours(img, [curCnt], -1, 255, 1)
249 250
        
        img = np.uint8(cv2.morphologyEx(img, cv2.MORPH_CLOSE, np.ones((3, 3))))
Josef Brandt's avatar
Josef Brandt committed
251 252 253
        return img, xmin, ymin, padding
    
    def imgToCnt(self, img, xmin, ymin, padding):
254 255 256 257 258 259
        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
260
            QtWidgets.QMessageBox.critical(self.viewparent, 'ERROR!', 
261
                                           'Particle contours are not connected and cannot be combined!')
JosefBrandt's avatar
JosefBrandt committed
262
            raise NotConnectedContoursError
263 264 265 266 267
        
        newContour = contours[0]
        for i in range(len(newContour)):
            newContour[i][0][0] += xmin-padding
            newContour[i][0][1] += ymin-padding
268
            
269
        return newContour
JosefBrandt's avatar
JosefBrandt committed
270
    
Josef Brandt's avatar
Josef Brandt committed
271 272 273 274 275 276 277
    def getTopLeft(self, contours):
        cnt = np.vstack(tuple(contours))  #combine contous
        #draw contours
        xmin = cnt[:,0,:][:, 0].min()
        ymin= cnt[:,0,:][:, 1].min()
        return ymin, xmin
    
JosefBrandt's avatar
JosefBrandt committed
278 279 280
    def mergeParticlesInParticleContainerAndSampleView(self, indices, newContour, stats, assignment):
        self.viewparent.addParticleContourToIndex(newContour, len(self.viewparent.contourItems)-1)
        self.particleContainer.addMergedParticle(indices, newContour, stats, newAssignment=assignment)
281
        
JosefBrandt's avatar
JosefBrandt committed
282 283 284
        for ind in sorted(indices, reverse=True):
            self.viewparent.removeParticleContour(ind)
            self.particleContainer.removeParticle(ind)
285
        
JosefBrandt's avatar
JosefBrandt committed
286 287 288 289
        self.viewparent.resetContourIndices()
        self.particleContainer.resetParticleIndices()
        self.particleAssignmentChanged.emit()
        #TODO: INCLUDE SANITY CHECK!!!!!!!!!
290
    
JosefBrandt's avatar
JosefBrandt committed
291 292 293 294 295 296
    @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
297

JosefBrandt's avatar
JosefBrandt committed
298 299 300 301 302 303 304
            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
305 306 307 308


class NotConnectedContoursError(Exception):
    pass
JosefBrandt's avatar
JosefBrandt committed
309
        
Hackmet's avatar
Hackmet committed
310