sampleview.py 23 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
# -*- coding: utf-8 -*-
"""
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/>.
"""
from PyQt5 import QtCore, QtGui, QtWidgets
import numpy as np
import os
24
import logging
25
import logging.handlers
26 27
from typing import TYPE_CHECKING

28
from .dataset import DataSet, loadData
29
from .instrumentcom.instrumentConfig import InstrumentControl, simulatedRaman
30
from .zeissimporter import ZeissImporter
31
from .helperfunctions import polygoncovering, lightModeSwitchNeeded
32
from .analysis.particleEditor import ParticleEditor
33
from .gui.configInstrumentUI import InstrumentConfigWin
34
from .scenePyramid import ScenePyramid
35
from .gepardlogging import setDefaultLoggingConfig
36
from .gui.viewItemHandler import ViewItemHandler
37 38
from .exportSLF import exportSLFFile

39 40
if TYPE_CHECKING:
    from __main__ import GEPARDMainWindow
41

42

43 44 45 46
class SampleView(QtWidgets.QGraphicsView):
    ScalingChanged = QtCore.pyqtSignal(float)
    ParticleOfIndexSelected = QtCore.pyqtSignal(int)
    
47
    def __init__(self, gepard: 'GEPARDMainWindow', logger: logging.Logger):
48
        super(SampleView, self).__init__()
49
        self.imparent: 'GEPARDMainWindow' = gepard
50
        self.logger = logger
51 52 53 54 55 56 57 58 59 60 61
        self.scaleFactor = 1.0
        scene = QtWidgets.QGraphicsScene(self)
        scene.setItemIndexMethod(QtWidgets.QGraphicsScene.NoIndex)
        scene.setBackgroundBrush(QtCore.Qt.darkGray)
        self.setScene(scene)
        self.setCacheMode(QtWidgets.QGraphicsView.CacheBackground)
        self.setViewportUpdateMode(QtWidgets.QGraphicsView.BoundingRectViewportUpdate)
        self.setRenderHint(QtGui.QPainter.Antialiasing)
        self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
        self.setResizeAnchor(QtWidgets.QGraphicsView.AnchorViewCenter)

62 63
        self.simulatedRaman = simulatedRaman
        if simulatedRaman:
64
            self.instrctrl = InstrumentControl(self.logger, ui=True)
65
        else:
66 67
            self.instrctrl = InstrumentControl(self.logger)
        self.lightModeSwitchNeeded = lightModeSwitchNeeded(self.instrctrl)
68 69 70
        
        self.drag = None
        self.dataset = None
Josef Brandt's avatar
Josef Brandt committed
71
        self.pyramid: ScenePyramid = ScenePyramid(self, self.logger)
72
        self.particleEditor = None
73 74

        self.viewItemHandler: ViewItemHandler = ViewItemHandler(self)
75 76 77 78 79
        self.particleInfoBox = None
        self.imgdata = None
        self.isblocked = False
        self.selectedParticleIndices = []
        self.particlePainter = None
Josef Brandt's avatar
Josef Brandt committed
80

81 82 83
        self.setMinimumSize(600, 600)
        self.darkenPixmap = False
        self.microscopeMode = None
84
        self.hiddenWidgets: list = []
85 86 87 88
        
    def takeScreenshot(self):
        self.setHorizontalScrollBarPolicy(1)
        self.setVerticalScrollBarPolicy(1)
89
        # capture screen
90 91 92
        screen = QtWidgets.QApplication.primaryScreen()
        self.repaint()
        screenshot = screen.grabWindow(self.winId())
93
        # unhide scrollbars
94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109
        self.setHorizontalScrollBarPolicy(0)
        self.setVerticalScrollBarPolicy(0)
        
        fname = self.dataset.path + '/screenshot.png'
        validFileName = False
        incr = 1
        while not validFileName:
            if not os.path.exists(fname):
                validFileName = True
            else:
                fname = self.dataset.path + '/screenshot ({}).png'.format(incr)
                incr += 1
        screenshot.save(fname , 'png')
        QtWidgets.QMessageBox.about(self, 'Message', 'Saved as {} to project directory.'.format(fname.split('/')[-1]))
                    
    def closeEvent(self, event):
110 111 112
        reply = QtWidgets.QMessageBox.question(self, 'Message', "Do you really want to quit?",
                                               QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
                                               QtWidgets.QMessageBox.No)
113 114 115 116

        if reply == QtWidgets.QMessageBox.Yes:
            self.disconnectRaman()
            self.saveDataSet()
Josef Brandt's avatar
Josef Brandt committed
117
            self.imparent.modeHandler.closeAll()
118
            self.imparent.forceCloseAll()
119 120
        else:
            event.ignore()
121

122
    def configureInstrumentControl(self):
123 124 125 126
        """
        Launches a window for updating Raman instrument configuration.
        :return:
        """
127
        self.configWin = InstrumentConfigWin(self)
128
        self.configWin.show()
129 130 131 132 133 134 135 136 137 138 139

    def exportAptsToSLF(self) -> None:
        progress: QtWidgets.QProgressDialog = QtWidgets.QProgressDialog()
        progress.setCancelButton(None)
        progress.setWindowModality(QtCore.Qt.WindowModal)

        self.dataset.particleContainer.updateFTIRApertures(self.pyramid, progressbar=progress)
        self.dataset.save()
        fname = exportSLFFile(self.dataset, progress)
        QtWidgets.QMessageBox.about(self, "Done", f"Particle and Aperture data saved to {fname} in project directory")

140 141 142 143 144 145 146 147
    def saveDataSet(self):
        if self.dataset is not None:
            self.dataset.save()

    def scrollContentsBy(self, dx: int, dy: int) -> None:
        super().scrollContentsBy(dx, dy)
        self.pyramid.onMove()

148 149 150 151
    def forceSceneUpdate(self) -> None:
        self.zoomDisplay(0.9)
        self.zoomDisplay(1 / 0.9)

152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175
    @QtCore.pyqtSlot()
    def zoomIn(self):
        self.zoomDisplay(1.25)

    @QtCore.pyqtSlot() 
    def zoomOut(self):
        self.zoomDisplay(0.8)

    @QtCore.pyqtSlot() 
    def normalSize(self):
        self.scaleFactor = 1.0
        self.setTransform(QtGui.QTransform.fromScale(1., 1.))
        self.announceScaling()

    @QtCore.pyqtSlot() 
    def fitToWindow(self):
        """
        Fits the window to show the entire sample.
        :return:
        """
        brect = self.scene().itemsBoundingRect()
        self.fitInView(0, 0, brect.width(), brect.height(), QtCore.Qt.KeepAspectRatio)
        self.scaleFactor = self.transform().m11()
        self.announceScaling()
Josef Brandt's avatar
Josef Brandt committed
176

177 178
    def open(self, fname):       
        self.saveDataSet()
Josef Brandt's avatar
Josef Brandt committed
179
        self.imparent.modeHandler.reset()
180
        self.dataset = loadData(fname)
181 182
        if self.instrctrl.name == 'ThermoFTIRCom' and not self.dataset.opticalScanDone:
            self.instrctrl.updateImageConfig(self.dataset.path)
183
        self.pyramid.setMicroscopeMode(self.microscopeMode)
184
        self._setUpToDataset()
185 186 187 188 189 190

    def new(self, fname):
        self.saveDataSet()
        if self.dataset is not None:
            self.dataset.save()
        self.dataset = DataSet(fname, newProject=True)
191 192
        if self.instrctrl.name == 'ThermoFTIRCom':
            self.instrctrl.updateImageConfig(self.dataset.path)
193 194 195 196
        self._setUpToDataset()

    def _setUpToDataset(self) -> None:
        """ Updating relevant objects after having created or loaded a dataset"""
Josef Brandt's avatar
Josef Brandt committed
197
        self.viewItemHandler.clearAll()
198 199 200
        self.setupParticleEditor()
        self.setupLoggerToDataset()
        self.viewItemHandler.updateFromDataset()
201
        self.pyramid.fromDataset(self.dataset)
202
        self.setMicroscopeMode()
203 204 205
        self.imparent.setWindowTitle(self.dataset.name + (" SIMULATION" if simulatedRaman else ""))
        self.imgdata = None
        self.imparent.snapshotAct.setEnabled(True)
206 207 208 209
        self.imparent.activateMaxMode(loadnew=True)
        self.forceSceneUpdate()

    def importProject(self, fname):
210
        zimp = ZeissImporter(fname, self.instrctrl, self)
211 212 213 214
        if zimp.validimport:
            zimp.exec()
            if zimp.result() == QtWidgets.QDialog.Accepted:
                self.open(zimp.gepardname)
Josef Brandt's avatar
Josef Brandt committed
215

216 217 218 219 220
    def setupLoggerToDataset(self):
        """
        Adds a new handler to the logger to create a log also in the dataset directory
        :return:
        """
Josef Brandt's avatar
Josef Brandt committed
221 222 223 224 225 226 227 228
        if self.dataset.path != '':
            logPath = os.path.join(self.dataset.path, 'gepardLog.txt')
            self.logger.info(f'creating new log handler to path: {logPath}')
            self.logger.addHandler(
                logging.handlers.RotatingFileHandler(
                    logPath, maxBytes=5 * (1 << 20), backupCount=10)
            )
            setDefaultLoggingConfig(self.logger)
229
        
230 231 232 233 234 235 236 237 238 239 240 241
    def setupParticleEditor(self):
        """
        Setting up the particle editor for editing properties or contours of particles. 
        It needs some connections to sampleview and analysisview.
        """
        def tryDisconnectingSignal(signal):
            try: 
                signal.disconnect()
            except TypeError:
                pass
            
        if self.particleEditor is None:
Josef Brandt's avatar
Josef Brandt committed
242
            self.particleEditor = ParticleEditor(self, self.dataset)
243 244 245
            
        tryDisconnectingSignal(self.particleEditor.particleAssignmentChanged)

Josef Brandt's avatar
Josef Brandt committed
246 247 248
    def getScenePyramid(self) -> ScenePyramid:
        return self.pyramid

249 250 251 252 253 254
    def setMicroscopeMode(self):
        """
        The opical microscope can be in either Brightfield (bf) or Darkfield (df) mode. In the case of the Renishaw instrument
        this mode affects the current image size in µm. Hence, gepard needs to be aware of the current mode
        :return:
        """
255 256 257 258
        if self.lightModeSwitchNeeded:
            self.imparent.lightModeSwitch.connectToSampleView()
            self.imparent.lightModeSwitch.show()
        self.microscopeMode = ('df' if self.imparent.lightModeSwitch.df_btn.isChecked() else 'bf')
259 260 261 262 263 264 265
    
    def blockUI(self):
        self.isblocked = True
        self.imparent.blockUI()
        
    def unblockUI(self):
        self.isblocked = False
266
        self.imparent.unblockUI(self.instrctrl.connected)
Josef Brandt's avatar
Josef Brandt committed
267 268
        modeHandler = self.imparent.modeHandler
        self.imparent.updateModes(self.imparent.getCurrentMode(), modeHandler.getMaxMode())
269 270
        
    def mousePressEvent(self, event):
271
        if event.button() == QtCore.Qt.MiddleButton:
272 273
            self.drag = event.pos()
        
274 275 276
        if self.particlePainter is not None:
            self.particlePainter.mousePressEvent(event)
        else:
Josef Brandt's avatar
Josef Brandt committed
277 278 279
            wasConsumedByMode: bool = self.imparent.modeHandler.mousePressed(event)
            if not wasConsumedByMode:
                super(SampleView, self).mousePressEvent(event)
280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314

    def mouseMoveEvent(self, event):
        if self.drag is not None:
            p0 = event.pos()
            move = self.drag-p0
            self.horizontalScrollBar().setValue(move.x() + self.horizontalScrollBar().value())
            self.verticalScrollBar().setValue(move.y() + self.verticalScrollBar().value())
            
            self.drag = p0
        elif self.particlePainter is None:
            p0 = self.mapToScene(event.pos())
            super(SampleView, self).mouseMoveEvent(event)
        else:
            self.particlePainter.mouseMoveEvent(event)
                        
    def mouseReleaseEvent(self, event):
        self.drag = None
        if self.particlePainter is None:
            super(SampleView, self).mouseReleaseEvent(event)
        else:
            self.particlePainter.mouseReleaseEvent(event)
        
    def wheelEvent(self, event):
        if self.particlePainter is None:
            factor = 1.01**(event.angleDelta().y()/8)
            self.zoomDisplay(factor)
        else:
            self.particlePainter.wheelEvent(event)
            
    def keyPressEvent(self, event):
        if self.particlePainter is not None:
            self.particlePainter.keyPressEvent(event)
    
    def moveStageToPosition(self, pos):
        """
315
        Sends a command to the instrcontrol to move the microscope stage to the position of the click event.
316 317 318 319 320 321 322 323 324 325 326 327 328
        :return:
        """
        if self.dataset is not None and self.dataset.pshift is not None:
            if self.dataset.readin:
                reply = QtWidgets.QMessageBox.critical(self, 'Dataset is newly read from disk!',
                    "Coordinate systems might have changed since. Do you want to continue with saved coordinates?", 
                    QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No)
        
                if reply == QtWidgets.QMessageBox.Yes:
                    self.dataset.readin = False
                else:
                    return

Josef Brandt's avatar
Josef Brandt committed
329
            noz = (self.imparent.getCurrentMode() in ['OpticalScan', 'SpectrumScan'])
330
            x, y, z = self.dataset.mapToLengthSpectrometer([pos.x(), pos.y()], microscopeMode=self.microscopeMode, noz=noz)
331
            if z is not None:
Josef Brandt's avatar
Josef Brandt committed
332
                assert z > -100.
333
            self.instrctrl.moveToAbsolutePosition(x, y, z)
334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365
    
    def checkForContourSelection(self, event):
        """
        Checks, if at the given event position any contour is selected.
        :return:
        """
        def acceptSelection(cnt):
            self.ParticleOfIndexSelected.emit(cnt.particleIndex)
            cnt.isSelected = True
            cnt.update()
            if cnt.particleIndex not in self.selectedParticleIndices:
                self.selectedParticleIndices.append(cnt.particleIndex)
                
        def removeContourFromSelection(cnt):
            cnt.isSelected = False
            cnt.update()
            self.selectedParticleIndices.remove(cnt.particleIndex)
        
        p = self.mapToScene(event.pos())
        p = QtCore.QPointF(p.x(), p.y())
        
        for index, cnt in enumerate(self.contourItems):
            
            if cnt.polygon.containsPoint(p, QtCore.Qt.OddEvenFill):   #clicked on particle
                if not event.modifiers()==QtCore.Qt.ShiftModifier:
                    acceptSelection(cnt)
                else:
                    if cnt.particleIndex not in self.selectedParticleIndices:
                        acceptSelection(cnt)
                    elif cnt.particleIndex in self.selectedParticleIndices:
                        removeContourFromSelection(cnt)
            
366
            else:  # not clicked on particle
367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384
                if event.modifiers()!=QtCore.Qt.ShiftModifier:
                    cnt.isSelected = False
                    cnt.update()
                    if cnt.particleIndex in self.selectedParticleIndices:
                        self.selectedParticleIndices.remove(cnt.particleIndex)

        if len(self.selectedParticleIndices) > 0:
            self.updateParticleInfoBox(self.selectedParticleIndices[-1])
        else:
            self.removeParticleInfoBox()
            
        self.update()

    def zoomDisplay(self, factor):
        """
        Zooms the GraphicsView in or out, according to the given factor.
        :return:
        """
385
        if factor < 1 and not self.imparent.zoomOutAct.isEnabled():
386
            return
387
        if factor > 1 and not self.imparent.zoomInAct.isEnabled():
388 389 390 391 392 393 394 395 396 397 398 399 400 401 402
            return
        self.scaleFactor *= factor      
        self.scale(factor, factor)
        self.pyramid.onScale()
        self.announceScaling()
        
    def announceScaling(self):
        """
        Processes a new scaling of the GraphicsView.
        :return:
        """
        pixelscale = self.dataset.getPixelScale(self.microscopeMode)
        if self.dataset is None or pixelscale is None:
            self.ScalingChanged.emit(-1.0)
        else:
403
            self.ScalingChanged.emit(pixelscale/self.scaleFactor)  # CURRENTLY ONLY DARKFIELD!!! FIX NEEDED!!!
404 405
        
    def connectRaman(self):
406
        if not self.instrctrl.connect():
407 408 409 410
            msg = QtWidgets.QMessageBox()
            msg.setText("Connection failed! Please enable remote control.")
            msg.exec()
        else:
Josef Brandt's avatar
Josef Brandt committed
411
            self.imparent.activateMaxMode()
412
        self.imparent.updateConnected(self.instrctrl.connected)
413 414
    
    def disconnectRaman(self):
415 416
        self.instrctrl.disconnect()
        self.imparent.updateConnected(self.instrctrl.connected)
417 418 419 420

    @QtCore.pyqtSlot(str)
    def detectionUpdate(self):
        """
421
        Is connected to particle detection. When a new segmentation result was obtained, the sampleview has to update.
422 423
        :return:
        """
424
        self.viewItemHandler.resetParticleViewItems()
Josef Brandt's avatar
Josef Brandt committed
425
        # self.viewItemHandler.switchToNewMode('ParticleDetection', 'SpectrumScan')
426

427 428 429 430 431
    @QtCore.pyqtSlot()
    def finishParticleDetection(self) -> None:
        self.imparent.updateModes('ParticleDetection', 'SpectrumScan')
        self.imparent.enableParticleRelevantActs()
        self.imparent.transpAct.setChecked(True)
432
        self.viewItemHandler.adjustParticleViewItemsVisibility()
433

Josef Brandt's avatar
Josef Brandt committed
434 435 436
    def prepareAnalysis(self) -> None:
        self.viewItemHandler.resetSpecScanItems()

437 438 439 440 441 442 443
    @QtCore.pyqtSlot(str)
    def loadPixmap(self, microscope_mode='df'):
        """
        Loads the pixmap image as background for the GraphicsView.
        It is stored in self.imgdata
        :return:
        """
444 445 446 447 448 449 450
        if self.dataset is not None:
            if self.darkenPixmap:
                self.scene().setBackgroundBrush(QtGui.QColor(5, 5, 5))
                self.pyramid.setTileOpacity(0.2)
            else:
                self.scene().setBackgroundBrush(QtCore.Qt.darkGray)
                self.pyramid.setTileOpacity(1)
451 452
            self.pyramid.initScene()

453
            if self.imparent.getCurrentMode() != "ParticleDetection":
454 455 456 457 458 459 460 461 462
                self.fitToWindow()

    @QtCore.pyqtSlot()
    def resetScanPositions(self):
        """
        Calculates position of scan tiles for the optical scan, according to the boundary and the polygoncovering method.
        The corresponding graphic items are added to the scene.
        :return:
        """
Josef Brandt's avatar
Josef Brandt committed
463
        micMode = self.imparent.modeHandler.getMicModeForOpticalScan()
464
        edges, nodes = self.viewItemHandler.edges, self.viewItemHandler.nodes
465 466 467 468
        boundary = []
        for n in nodes:
            p = n.pos().x(), n.pos().y()
            boundary.append(self.dataset.mapToLength(p, self.microscopeMode, force=True))
Josef Brandt's avatar
Josef Brandt committed
469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492
        if len(boundary) > 0:
            boundary = np.array(boundary)
            self.dataset.boundary = boundary
            if micMode == 'df':
                width, height, angle = self.dataset.imagedim_df
            else:
                width, height, angle = self.dataset.imagedim_bf

            margin = min(width, height)*0.02
            wx, wy = width-margin, height-margin
            p1 = polygoncovering(boundary, wx, wy)
            b2 = boundary.copy()
            b2 = b2[:, [1, 0]]
            p2 = polygoncovering(b2, wy, wx)
            if len(p2) < len(p1):
                p1 = [[pi[1], pi[0]] for pi in p2]
            self.dataset.grid = p1
            pixelscale = self.dataset.getPixelScale(self.microscopeMode)
            wxs, wys = width/pixelscale, height/pixelscale
            self.viewItemHandler.resetOpticalScanIndicators(wxs, wys)
            # self.viewItemHandler.resetOptScanItems()
            # for i, p in enumerate(p1):
            #     p = self.dataset.mapToPixel(p, self.microscopeMode, force=True)
            #     self.viewItemHandler.addOptScanItem(i+1, wxs, wys, p)
493 494 495 496 497 498 499
    
    @QtCore.pyqtSlot()        
    def resetBoundary(self):
        """
        Resets the Boundary Items in the GraphicsView.
        :return:
        """
Josef Brandt's avatar
Josef Brandt committed
500 501 502 503 504 505 506 507
        # micMode = self.microscopeMode
        # self.viewItemHandler.resetOptScanBoundary()
        # for p in self.dataset.boundary:
        #     p = self.dataset.mapToPixel(p, micMode, force=True)
        #     self.viewItemHandler.addNode(p)
        # for i in range(len(self.viewItemHandler.nodes)):
        #     self.viewItemHandler.addEdge(self.viewItemHandler.nodes[i-1], self.viewItemHandler.nodes[i])
        self.viewItemHandler.resetOpticalScanSetupItems()
508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549
        self.resetScanPositions()
    
    def removeParticleContour(self, contourIndex):
        correspondingParticle = self.dataset.particleContainer.getParticleOfIndex(contourIndex)   #this checks validity of contourIndex
        self.scene().removeItem(self.contourItems[contourIndex])
        del self.contourItems[contourIndex]
    
    def resetContourIndices(self):
        for index, contour in enumerate(self.contourItems):
            contour.setIndex(index)    
    
    def updateLegend(self, legendItems):
        """
        Updates the color legend in the graphics view.
        :return:
        """
        self.imparent.legend.setTextColorItems(legendItems)
    
    def highLightContour(self, index):
        """
        Highlights a contour and fits the GraphicsView to the particle and its infobox.
        :return:
        """
        for contour in self.contourItems:
            contour.isSelected = False
            
        contour = self.contourItems[index]
        contour.isSelected = True
        self.selectedParticleIndices =[index]
       
        self.updateParticleInfoBox(index)
        self.fitToParticleAndInfoBox()
        self.update()
    
    def updateParticleInfoBox(self, particleIndex):
        self.removeParticleInfoBox()
        self.addParticleInfoBox(particleIndex)
        
    def removeParticleInfoBox(self):
        if self.particleInfoBox is not None:
            self.scene().removeItem(self.particleInfoBox)
            self.particleInfoBox = None
550

551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591
    def fitToParticleAndInfoBox(self):
        """
        Fits the view to display the current particleInfoBox and its associated particle
        ;return:
        """
        def getGlobalBrect(graphItem):
            """
            Calculates bounding rect relative to scene origin (0,0)
            :return:
            """
            top = graphItem.pos().y() + graphItem.brect.top()
            left = graphItem.pos().x() + graphItem.brect.left()
            width = graphItem.brect.width()
            height = graphItem.brect.height()
            return QtCore.QRectF(left, top, width, height)
        
        def combineRects(rect1, rect2, margin=20):
            """
            Calculates a combined bounding rect.
            :return:
            """
            top = min(rect1.top(), rect2.top()) - margin/2
            left = min(rect1.left(), rect2.left()) - margin/2
            bottom = max((rect1.top()+rect1.height()), (rect2.top()+rect2.height())) + margin/2
            right = max((rect1.left()+rect1.width()), (rect2.left()+rect2.width())) + margin/2
            return QtCore.QRectF(left, top, (right-left), (bottom-top))
    
        infoBoxRect = getGlobalBrect(self.particleInfoBox)
        particleIndex = self.particleInfoBox.particle.index
        selectedContour = self.contourItems[particleIndex]
        contourRect = getGlobalBrect(selectedContour)
        viewRect = combineRects(infoBoxRect, contourRect)
        self.fitInView(viewRect, QtCore.Qt.KeepAspectRatio)
        self.scaleFactor = self.transform().m11()
        self.announceScaling()
    
    def centerOnRamanIndex(self, index):
        """
        Centers the graphicsView to a given ScanIndicator Item.
        :return:
        """
592
        for scanItem in self.viewItemHandler.specscanItems:
593 594
            if scanItem.number == index+1:
                self.centerOn(scanItem)
595
                break