particleeditor.py 11.8 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
31

JosefBrandt's avatar
JosefBrandt committed
32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
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
53

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

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

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

        for particleIndex in self.selectedParticleIndices:
JosefBrandt's avatar
 
JosefBrandt committed
76 77 78 79
            try:
                assignment = self.particleContainer.getParticleAssignmentByIndex(particleIndex)
            except:
                return
JosefBrandt's avatar
 
JosefBrandt committed
80
            selctedAssignments.append(assignment)
JosefBrandt's avatar
 
JosefBrandt committed
81
        
JosefBrandt's avatar
 
JosefBrandt committed
82
        for assignment in np.unique(selctedAssignments):
JosefBrandt's avatar
 
JosefBrandt committed
83
            self.combineActs.append(self.combineMenu.addAction(assignment))
JosefBrandt's avatar
 
JosefBrandt committed
84
            self.paintActs.append(self.paintMenu.addAction(assignment))
JosefBrandt's avatar
 
JosefBrandt committed
85
        self.combineActs.append(self.combineMenu.addAction("other"))
JosefBrandt's avatar
 
JosefBrandt committed
86 87
        self.paintActs.append(self.paintMenu.addAction("other"))
        
JosefBrandt's avatar
 
JosefBrandt committed
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103
        
        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
104
        self.addMenu(self.paintMenu)
JosefBrandt's avatar
JosefBrandt committed
105
        self.deleteAct = self.addAction("Delete particle(s)")
JosefBrandt's avatar
 
JosefBrandt committed
106 107 108
        
        action = self.exec_(screenPos)

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

            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
118 119
            elif action == self.deleteAct:
                self.deleteParticlesSignal.emit(self.selectedParticleIndices)
120 121 122 123 124 125 126
    
    def validifyAssignment(self, assignment):
        if assignment == "other":
            assignment = self.getNewEntry()
        return assignment
    
    def getNewEntry(self):
JosefBrandt's avatar
 
JosefBrandt committed
127
        text, okClicked = QtWidgets.QInputDialog.getText(self.viewparent, "Custom assignment", "Enter new assignment")
128 129
        if okClicked and text != '':
            return text
JosefBrandt's avatar
 
JosefBrandt committed
130 131 132


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

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

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

        img = np.zeros((rangey, rangex))
        for curCnt in contours:
            for i in range(len(curCnt)):
                curCnt[i][0][0] -= xmin-padding
                curCnt[i][0][1] -= ymin-padding
                
            cv2.drawContours(img, [curCnt], -1, 1, -1)
            cv2.drawContours(img, [curCnt], -1, 1, 1)
        
        img = np.uint8(cv2.morphologyEx(img, cv2.MORPH_CLOSE, np.ones((3, 3))))
        
        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
247
            QtWidgets.QMessageBox.critical(self.viewparent, 'ERROR!', 
248
                                           'Particle contours are not connected and cannot be combined!')
JosefBrandt's avatar
JosefBrandt committed
249
            raise NotConnectedContoursError
250 251 252 253 254
        
        newContour = contours[0]
        for i in range(len(newContour)):
            newContour[i][0][0] += xmin-padding
            newContour[i][0][1] += ymin-padding
255
            
256
        return newContour
JosefBrandt's avatar
JosefBrandt committed
257 258 259 260
    
    def mergeParticlesInParticleContainerAndSampleView(self, indices, newContour, stats, assignment):
        self.viewparent.addParticleContourToIndex(newContour, len(self.viewparent.contourItems)-1)
        self.particleContainer.addMergedParticle(indices, newContour, stats, newAssignment=assignment)
261
        
JosefBrandt's avatar
JosefBrandt committed
262 263 264
        for ind in sorted(indices, reverse=True):
            self.viewparent.removeParticleContour(ind)
            self.particleContainer.removeParticle(ind)
265
        
JosefBrandt's avatar
JosefBrandt committed
266 267 268 269
        self.viewparent.resetContourIndices()
        self.particleContainer.resetParticleIndices()
        self.particleAssignmentChanged.emit()
        #TODO: INCLUDE SANITY CHECK!!!!!!!!!
270
    
JosefBrandt's avatar
JosefBrandt committed
271 272 273 274 275 276
    @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
277

JosefBrandt's avatar
JosefBrandt committed
278 279 280 281 282 283 284
            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
285 286 287 288


class NotConnectedContoursError(Exception):
    pass
JosefBrandt's avatar
JosefBrandt committed
289
        
Hackmet's avatar
Hackmet committed
290