Commit 8f92cb2a authored by Josef Brandt's avatar Josef Brandt

Better Simulated Instrument

With simulated stage and camera
parent 5033b93c
......@@ -72,9 +72,10 @@ class GEPARDMainWindow(QtWidgets.QMainWindow):
def closeEvent(self, event):
def scalingChanged(self, scale):
def scalingChanged(self, scale: float):
self.zoomInAct.setEnabled(self.view.scaleFactor < 20.0)
self.zoomOutAct.setEnabled(self.view.scaleFactor > .01)
self.normalSizeAct.setEnabled(self.view.scaleFactor != 1.)
......@@ -82,8 +83,7 @@ class GEPARDMainWindow(QtWidgets.QMainWindow):
def open(self, fileName=False):
if fileName is False:
fileName = QtWidgets.QFileDialog.getOpenFileName(self, "Open Project",
defaultPath, "*.pkl")[0]
fileName = QtWidgets.QFileDialog.getOpenFileName(self, "Open Project", defaultPath, "*.pkl")[0]
if fileName:
self.fname = str(fileName)
......@@ -338,9 +338,20 @@ class GEPARDMainWindow(QtWidgets.QMainWindow):
self.addToolBar(QtCore.Qt.LeftToolBarArea, self.toolbar)
if __name__ == '__main__':
import sys
from time import localtime, strftime
def closeAll() -> None:
Closes the app and, with that, all windows.
Josef: I implemented this, as with the simulated microscope stage it was difficult to find a proper way to
ONLY close it at the end of running the program. Closing it on disconnect of the ramanctrl is not suitable,
as it should be opened also in disconnected stage (e.g., when another instance is running in optical or raman
scan, but the UI (disconnected) should still update what's going on.
def excepthook(excType, excValue, tracebackobj):
......@@ -30,6 +30,9 @@ except ImportError:
skimread = None
skimsave = None
from .ramancom.ramanbase import RamanBase
from logging import Logger
def cv2imread_fix(fname, flags=cv2.IMREAD_COLOR):
if skimread is not None:
return skimread(fname, as_gray=(flags==cv2.IMREAD_GRAYSCALE))
......@@ -95,4 +98,15 @@ def polygoncovering(boundary, wx, wy):
poslist.extend([[xi,yi+.5*wy] for xi in (x if i%2==0 else x[::-1])])
x0clast, x1clast = x0c, x1c
return poslist
\ No newline at end of file
def getRamanControl(controlclass: RamanBase, logger: Logger) -> RamanBase:
simulatedInterface: bool = False
if 'simulatedInterface' in controlclass.__dict__.keys():
if controlclass.__dict__['simulatedInterface'] == True:
simulatedInterface = True
if simulatedInterface:
ramanctrl = controlclass(logger, ui=False)
ramanctrl = controlclass(logger)
return ramanctrl
......@@ -133,7 +133,7 @@ class BackGroundManager(QtWidgets.QWidget):
if img is not None:
prevImg = img
prevImg = np.zeros((300, 300))
prevImg = np.zeros((300, 300, 3))
if convertColors:
prevImg = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
......@@ -23,12 +23,13 @@ from PyQt5 import QtCore, QtWidgets
import numpy as np
from multiprocessing import Process, Queue, Event
import queue
from .imagestitch import imageStacking
import os, sys
import os
import logging
import logging.handlers
import cv2
from .helperfunctions import cv2imread_fix, cv2imwrite_fix
from typing import List
from .imagestitch import imageStacking
from .helperfunctions import cv2imread_fix, cv2imwrite_fix, getRamanControl
from time import time, localtime, strftime
from .opticalbackground import BackGroundManager
from .uielements import TimeEstimateProgressbar
......@@ -51,7 +52,7 @@ def scan(path, sol, zpositions, grid, controlclass, dataqueue,'starting new optical scan')
ramanctrl = controlclass(logger)
ramanctrl = getRamanControl(controlclass, logger)
zlist = list(enumerate(zpositions))
for i, p in enumerate(grid):
......@@ -135,8 +136,8 @@ def removeSrcTiles(names, path):
def loadAndPasteImage(srcnames, pyramid, fullzval, width, height,
rotationvalue, p0, p1, p, logger, background=None):
def loadAndPasteImage(srcnames: List[str], pyramid, fullzval: np.ndarray, width: float, height: float,
rotationvalue: float, p0: List[float], p1: List[float], p: List[float], logger, background=None):
:param list of str srcnames: list of stacked scan files to merge
:param ScenePyramid pyramid: the scene pyramid
......@@ -147,6 +148,7 @@ def loadAndPasteImage(srcnames, pyramid, fullzval, width, height,
:param list of float p0: (min x; max y) of scan tile positions
:param list of float p1: (max x; min y) of scan tile positions
:param list of float p: position of current scan tile
:param logger: the logger to use
:param numpy.ndarray background:
......@@ -276,24 +278,24 @@ class PointCoordinates(QtWidgets.QGridLayout):
def read(self, index):
x, y, z = self.ramanctrl.getPosition()
z = self.ramanctrl.getUserZ()
if index>=0:
if index >= 0:
wx, wy, wz = self.dswidgets[index]
self.validpoints[index] = True
self.readPoint.emit(x, y, z)
def getPoints(self):
points = np.zeros((self.N, 3), dtype=np.double)
for i in range(self.N):
if self.validpoints[i]:
wx, wy, wz = self.dswidgets[i]
points[i,0] = wx.value()
points[i,1] = wy.value()
points[i,2] = wz.value()
points[i, 0] = wx.value()
points[i, 1] = wy.value()
points[i, 2] = wz.value()
points[i,:] = np.nan
points[i, :] = np.nan
return points
......@@ -353,7 +355,7 @@ class OpticalScan(QtWidgets.QWidget):
self.pexit = QtWidgets.QPushButton("Cancel", self)
self.progressbar = TimeEstimateProgressbar()
......@@ -448,7 +450,7 @@ class OpticalScan(QtWidgets.QWidget):
def stopScan(self):
def cancelScan(self):
if self.process is not None and self.process.is_alive():
reply = QtWidgets.QMessageBox.question(self, 'Stop optical scan?',
"Do you want to terminate the running scan?",
......@@ -458,14 +460,12 @@ class OpticalScan(QtWidgets.QWidget):
def areaSelect(self):
......@@ -43,7 +43,7 @@ except KeyError:
if interface == "SIMULATED_RAMAN_CONTROL":
from .simulatedraman import SimulatedRaman
from .simulated.simulatedraman import SimulatedRaman
RamanControl = SimulatedRaman
print("WARNING: using only simulated raman control!")
simulatedRaman = True
# -*- coding: utf-8 -*-
GEPARD - Gepard-Enabled PARticle Detection
Copyright (C) 2018 Lars Bittrich and Josef Brandt, Leibniz-Institut für
Polymerforschung Dresden e. V. <>
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
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 <>.
Image Generator for fake images for testing with simulated instrument interfaces
import cv2
import numpy as np
import matplotlib.pyplot as plt
import time
import noise
from copy import deepcopy
from typing import Tuple, List
def particleIsVisible(particle: np.ndarray, full: np.ndarray, pos: Tuple[int, int]) -> bool:
:param full: image of the current particle
:param particle: image with all particles
:param pos: x, y of the particle center in pixels
isVisible: bool = False
height, width = particle.shape[:2]
if pos[0] + width/2 >= 0 and pos[0] - width/2 < full.shape[1]:
if pos[1] + height/2 >= 0 and pos[1] - height/2 < full.shape[0]:
isVisible = True
return isVisible
def addTexture(partImage: np.ndarray) -> np.ndarray:
shape = partImage.shape
startX = np.random.randint(0, 100)
startY = np.random.randint(0, 100)
for i in range(shape[0]):
for j in range(shape[1]):
noiseVal: float = noise.pnoise2((startY + i)/25, (startX + j)/25, octaves=4, persistence=0.5)
noiseVal = np.clip(noiseVal + 0.5, 0.0, 1.0) # bring to 0...1 range
partImage[i, j, :] *= (noiseVal**0.3)
return partImage
class OverlapRange:
fullStartX: int = np.nan
fullEndX: int = np.nan
fullStartY: int = np.nan
fullEndY: int = np.nan
subStartX: int = np.nan
subEndX: int = np.nan
subStartY: int = np.nan
subEndY: int = np.nan
def getParticleOverlapRanges(full: np.ndarray, sub: np.ndarray, centerXY: Tuple[int, int]) -> Tuple[bool, OverlapRange]:
ranges: OverlapRange = OverlapRange()
ranges.fullStartX = int(round(centerXY[0] - sub.shape[1]/2))
ranges.fullEndX = ranges.fullStartX + sub.shape[1]
ranges.fullStartY = int(round(centerXY[1] - sub.shape[0]/2))
ranges.fullEndY = ranges.fullStartY + sub.shape[0]
origRanges = deepcopy(ranges)
if ranges.fullEndX < 0 or ranges.fullEndY < 0 or ranges.fullStartX >= full.shape[1] or ranges.fullStartY >= full.shape[0]:
success = False
success = True
if origRanges.fullStartX >= 0:
ranges.subStartX = 0
ranges.subStartX = -origRanges.fullStartX
ranges.fullStartX = 0
if origRanges.fullEndX <= full.shape[1]:
ranges.subEndX = sub.shape[1]
diff: int = origRanges.fullEndX - full.shape[1]
ranges.subEndX = sub.shape[1] - diff
ranges.fullEndX = full.shape[1]
if origRanges.fullStartY >= 0:
ranges.subStartY = 0
ranges.subStartY = -origRanges.fullStartY
ranges.fullStartY = 0
if origRanges.fullEndY <= full.shape[0]:
ranges.subEndY = sub.shape[0]
diff: int = origRanges.fullEndY - full.shape[0]
ranges.subEndY = sub.shape[0] - diff
ranges.fullEndY = full.shape[0]
return success, ranges
class FakeParticle:
""" A fake spherical particle"""
x: float = np.nan # in µm
y: float = np.nan # in µm
radius: float = np.nan # in µm
randImageIndex: int = np.nan
class FakeCamera:
# TODO: Implement a small angle to simulate a tilted camera!
def __init__(self):
self.imgDims: Tuple[int, int] = (500, 250) # width, height of camera imgage
self.pixelscale: float = 1.0 # µm/px
self.sizeScale: float = 100.0 # smaller values make particles rendered smaller and vice versa
self.threshold: float = 0.7 # threshold for determining particles. Larger values -> smaller particles
self.numZLevels: int = 7 # number of z-levels cached for faking depth of field
self.maxZDiff: float = 100 # max difference in z that is simulated.
self.currentImage: np.ndarray = np.zeros((self.imgDims[1], self.imgDims[0], 3))
self.fakeFilter: FakeFilter = FakeFilter()
def updateImageAtPosition(self, position: List[float]) -> None:
Centers the camera at the given µm coordinates and returns the camera image centered at this position.
:param position: tuple (x in µm, y in µm, z in µm)
particles: np.ndarray = np.zeros((self.imgDims[1], self.imgDims[0], 3), dtype=np.uint8)
for particle in self.fakeFilter.particles:
pixelX, pixelY = self._getParticlePixelCoords((position[0], position[1]), particle)
blurRadius: int = int(round(abs(position[2] - particle.radius) / 2)) # we just use radius as height..
if blurRadius % 2 == 0:
blurRadius += 1
particleImg: np.ndarray = self.fakeFilter.getParticleImage(particle, blurRadius=blurRadius)
particles = self._addParticleImg(particles, particleImg, (pixelX, pixelY))
for i in range(3):
particles[:, :, i] = np.flipud(particles[:, :, i])
self.currentImage = particles
def _addParticleImg(self, allParts: np.ndarray, curPart: np.ndarray, position: Tuple[int, int]) -> np.ndarray:
Adds the current particle image to the image with all particles.
:param allParts: image with all particles
:param curPart: image of the current particle
:param position: x, y of the particle center
def overlayImages(img1: np.ndarray, img2: np.ndarray) -> np.ndarray:
assert img1.shape == img2.shape
merge = np.zeros_like(img1)
mask = np.sum(img1, axis=2) > 128
merge[mask] = img1[mask]
merge[~mask] = img2[~mask]
return np.uint8(merge)
particleFits, ranges = getParticleOverlapRanges(allParts, curPart, position)
if particleFits:
img1 = allParts[ranges.fullStartY:ranges.fullEndY, ranges.fullStartX:ranges.fullEndX, :]
img2 = curPart[ranges.subStartY:ranges.subEndY, ranges.subStartX:ranges.subEndX, :]
assert img1.shape == img2.shape
blend = overlayImages(img1, img2)
allParts[ranges.fullStartY:ranges.fullEndY, ranges.fullStartX:ranges.fullEndX, :] = blend
return allParts
def _getParticlePixelCoords(self, position: Tuple[float, float], particle: FakeParticle) -> Tuple[int, int]:
"""Converts the absolute particle x,y coordinates into image pixel coordinates
:param position: x and y coordinates of stage center (in µm)
:param particle: the particle in consideration
:return (x, y): pixel Coordinates of x and y of particle in image
imgHeightMicrons, imgWidthMicrons = self.imgDims[1] * self.pixelscale, self.imgDims[0] * self.pixelscale
upperLeftX, upperLeftY = position[0] - imgWidthMicrons/2, position[1] - imgHeightMicrons/2
particlePixelX: int = int(round((particle.x - upperLeftX) / self.pixelscale))
particlePixelY: int = int(round((particle.y - upperLeftY) / self.pixelscale))
return particlePixelX, particlePixelY
class FakeFilter:
def __init__(self):
self.numParticles: int = 500
self.xRange: Tuple[float, float] = (-3000, 3000) # min and max of x Dimensions (in µm)
self.yRange: Tuple[float, float] = (-3000, 3000) # min and max of x Dimensions (in µm)
self.particleSizeRange: Tuple[float, float] = (5, 50) # min and max of particle radius (in µm)
self.numIndParticles: int = 10 # number of individual particles presets to generate
self.numBlurSteps: int = 7 # number of blur steps to simulate (additionally to image without any blur)
self.maxBlurSize: int = 51 # highest blur radius
self.baseImageSize: int = 100
self.presetParticles: List[dict] = self._generagePresetParticles()
self.particles: List[FakeParticle] = []
def getParticleImage(self, particle: FakeParticle, blurRadius: int = 0) -> np.ndarray:
blurredImages: dict = self.presetParticles[particle.randImageIndex]
if blurRadius == 0:
img: np.ndarray = blurredImages[0]
availableRadii: np.ndarray = np.array([i for i in blurredImages.keys()])
closestRadius = availableRadii[np.argmin(abs(availableRadii-blurRadius))]
img: np.ndarray = blurredImages[closestRadius]
scaleFac: float = particle.radius / (self.baseImageSize/2)
return cv2.resize(img, None, fx=scaleFac, fy=scaleFac)
def _generagePresetParticles(self) -> List[dict]:
The list contains a dict for each particle variation, containing the image in different blur stages.
The key of that dicts is the used blur radius
# TODO: Consider moving that into a separate thread.. it takes quite some seconds at startup..
def getBlurLevels() -> np.ndarray:
levels: np.ndarray = np.linspace(1, self.maxBlurSize, self.numBlurSteps)
for j in range(len(levels)):
levels[j] = int(round(levels[j]))
if levels[j] % 2 == 0:
levels[j] += 1
return levels
baseSize = self.baseImageSize
presetParticles: List[dict] = []
for i in range(self.numIndParticles):
baseImg: np.ndarray = np.zeros((baseSize + self.maxBlurSize, baseSize + self.maxBlurSize, 3))
centerX = centerY = int(round(baseImg.shape[0] / 2))
radius: int = int(round(baseSize/2))
rgb: np.ndarray = 0.7 + np.random.rand(3) * 0.3 # desaturate a bit..
randColor = (int(rgb[0] * 255), int(rgb[1] * 255), int(rgb[2] * 255)), (centerX, centerY), radius, randColor, thickness=-1)
baseImg = np.uint8(addTexture(baseImg))
newParticleDict: dict = {0: baseImg}
for radius in getBlurLevels():
radius = int(radius)
newParticleDict[radius] = cv2.GaussianBlur(baseImg, (radius, radius), sigmaX=0)
return presetParticles
def _generateParticles(self) -> None:
self.particles = []
for i in range(self.numParticles):
rands: np.ndarray = np.random.rand(3)
newParticle: FakeParticle = FakeParticle()
newParticle.x = self.xRange[0] + rands[0]*(self.xRange[1]-self.xRange[0])
newParticle.y = self.yRange[0] + rands[1] * (self.yRange[1] - self.yRange[0])
newParticle.radius = self.particleSizeRange[0] + rands[2] * (
self.particleSizeRange[1] - self.particleSizeRange[0])
newParticle.randImageIndex = np.random.randint(0, self.numIndParticles)
# -*- coding: utf-8 -*-
GEPARD - Gepard-Enabled PARticle Detection
Copyright (C) 2018 Lars Bittrich and Josef Brandt, Leibniz-Institut für
Polymerforschung Dresden e. V. <>
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
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 <>.
Simulates a Microscope Stage with Camera.
import os
from PyQt5 import QtWidgets, QtGui, QtCore
import cv2
import json
import numpy as np
from typing import List
from .imageGenerator import FakeCamera
from ...coordinatetransform import getRotMat
except ImportError:
from imageGenerator import FakeCamera
class SimulatedStage(object):
def __init__(self, ui: bool = True):
super(SimulatedStage, self).__init__()
self.currentpos: List[float] = [0.0, 0.0, 0.0] # µm pos in x, y and z
self.offset: List[float] = [0.0, 0.0, 0.0] # µm offset in x, y and z
self.rotMatrix: np.ndarray = np.zeros((3, 3)) FakeCamera = FakeCamera()
self.filepath: str = self._getFilePath()
self.uiEnabled = ui
if self.uiEnabled:
self.ui: SimulatedStageUI = SimulatedStageUI(self)
def getCurrentCameraImage(self) -> np.ndarray:
def moveToPosition(self, x: float, y: float, z: float):
self.currentpos = [x, y, z]
if self.uiEnabled:
def connect(self) -> None:
if self.uiEnabled:
def disconnect(self) -> None:
if self.uiEnabled:
def updateConfigFromFile(self) -> None:
if os.path.exists(self.filepath):
with open(self.filepath, 'r') as fp:
configHasChanged: bool = self._dictToConfig(json.load(fp))
except json.JSONDecodeError:
# TODO: get reference to logger and log info that loading config failed..
print('failed updating simulated stage state from file..')
if configHasChanged:
if self.uiEnabled:
def saveConfigToFile(self) -> None:
if self.filepath != '':
with open(self.filepath, 'w') as fp:
json.dump(self._configToDict(), fp)
def _getFilePath(self) -> str: QtWidgets.QApplication = QtWidgets.QApplication(sys.argv) # has to be an instance attribute :/"GEPARD")
path: str = QtCore.QStandardPaths.writableLocation(QtCore.QStandardPaths.AppLocalDataLocation)
return os.path.join(path, 'simulatedStageConfig.txt')
def _configToDict(self) -> dict:
return {'currentPos': self.currentpos}
def _dictToConfig(self, inputDict: dict) -> bool:
configChanged: bool = False
if self.currentpos != inputDict['currentPos']:
self.currentpos = inputDict['currentPos']
configChanged = True
return configChanged
class SimulatedStageUI(QtWidgets.QWidget):
def __init__(self, stageParent: SimulatedStage):
super(SimulatedStageUI, self).__init__()
self.stageParent: SimulatedStage = stageParent
self.updateTimer: QtCore.QTimer = QtCore.QTimer()
self.setWindowTitle('Simulated Microscope Stage')
coordsGroup: QtWidgets.QGroupBox = QtWidgets.QGroupBox('Stage Coordinates')
coordsLayout: QtWidgets.QHBoxLayout = QtWidgets.QHBoxLayout()
self.labelX: QtWidgets.QLabel = QtWidgets.QLabel()
self.labelY: QtWidgets.QLabel = QtWidgets.QLabel()
self.labelZ: QtWidgets.QLabel = QtWidgets.QLabel()
for label in [self.labelX, self.labelY, self.labelZ]: