Commit 38f99d4e authored by Josef Brandt's avatar Josef Brandt

Merge remote-tracking branch 'origin/SegmentationRefactoring' into Tiling2Develop

parents f1fcb2d7 2efa7d08
......@@ -27,6 +27,7 @@ from threading import Thread
from .segmentation import Segmentation
from .analysis.particleCharacterization import getParticleStatsWithPixelScale, loadZValImageFromDataset
from .errors import InvalidParticleError
from .uielements import TimeEstimateProgressbar
from .scenePyramid import ScenePyramid
Nscreen = 1000
......@@ -290,8 +291,10 @@ class ParticleDetectionView(QtWidgets.QWidget):
self.img = pyramid.getFullImage()
self.imgclip = 0, 0, 0, 0
self.seg = Segmentation(self.dataset, self)
self.seg.detectionState.connect(self.updateDetectionState)
self.thread = None
self.view : QtWidgets.QGraphicsView = parent
self.threadrunning = False
self.view = parent
vbox = QtWidgets.QVBoxLayout()
hbox = QtWidgets.QHBoxLayout()
......@@ -315,7 +318,7 @@ class ParticleDetectionView(QtWidgets.QWidget):
self.showseedpoints.setChecked(True)
self.setImageCenter()
group = QtWidgets.QGroupBox("Detection settings", self)
self.detectParamsGroup = QtWidgets.QGroupBox("Detection settings", self)
grid = QtWidgets.QGridLayout()
self.parameters = []
checkBoxesToLink = {}
......@@ -397,8 +400,8 @@ class ParticleDetectionView(QtWidgets.QWidget):
grid.addWidget(self.showseedpoints, i+2, 0, 1, 2, QtCore.Qt.AlignLeft)
grid.addWidget(QtWidgets.QLabel("Click mouse to add seeds, Click+Shift to add deletepoints"), i+3, 0, 1, 2, QtCore.Qt.AlignLeft)
grid.addWidget(QtWidgets.QLabel("Click+Alt removes seeds near cursor"), i+4, 0, 1, 2, QtCore.Qt.AlignLeft)
group.setLayout(grid)
vbox.addWidget(group)
self.detectParamsGroup.setLayout(grid)
vbox.addWidget(self.detectParamsGroup)
self.updateSeedsInSampleViewBtn = QtWidgets.QPushButton("Update Seedpoints in fullimage view", self)
self.updateSeedsInSampleViewBtn.released.connect(self.updateSeedsInSampleview)
......@@ -420,6 +423,9 @@ class ParticleDetectionView(QtWidgets.QWidget):
self.autoUpdateCheckBox.setChecked(True)
vbox.addWidget(self.autoUpdateCheckBox)
self.progressbar = TimeEstimateProgressbar()
vbox.addWidget(self.progressbar)
hbox2 = QtWidgets.QHBoxLayout()
self.pdetectsub = QtWidgets.QPushButton("Detect", self)
self.pdetectall = QtWidgets.QPushButton("Detect all", self)
......@@ -634,6 +640,7 @@ class ParticleDetectionView(QtWidgets.QWidget):
self.updateImageSeeds()
def detectShow(self, showname):
if not self.threadrunning:
self.saveDetectParams(self.dataset)
img = self.subimg.copy()
kwargs = {}
......@@ -671,16 +678,26 @@ class ParticleDetectionView(QtWidgets.QWidget):
def blockUI(self):
self.pdetectsub.setEnabled(False)
self.pclear.setEnabled(False)
self.detectParamsGroup.setEnabled(False)
self.updateSeedsInSampleViewBtn.setEnabled(False)
self.hideSeedsInSampleViewBtn.setEnabled(False)
def unBlockUI(self):
self.pdetectsub.setEnabled(True)
self.pclear.setEnabled(True)
self.detectParamsGroup.setEnabled(True)
self.updateSeedsInSampleViewBtn.setEnabled(True)
self.hideSeedsInSampleViewBtn.setEnabled(True)
def raiseWarning(self, warning):
QtWidgets.QMessageBox.critical(self, "Warning", warning)
@QtCore.pyqtSlot()
def detectParticles(self):
"""
Detect all particles
:return:
"""
self.saveDetectParams(self.dataset)
if self.thread is not None and self.thread.is_alive():
self.cancelThread()
......@@ -700,6 +717,7 @@ class ParticleDetectionView(QtWidgets.QWidget):
if self.thread is not None:
if not self.threadrunning:
self.thread = None
self.progressbar.disable()
self.unBlockUI()
self.pdetectall.setText("Detect all")
self.imageUpdate.emit(self.view.microscopeMode)
......@@ -710,6 +728,25 @@ class ParticleDetectionView(QtWidgets.QWidget):
else:
self.timer.start(100.)
@QtCore.pyqtSlot(str)
def updateDetectionState(self, message):
"""
Updates the progressbar and its text-label to the current state of the detection
:return:
"""
if message.find('DO') == -1:
self.progressbar.setMessage(message)
else:
if message.find('setup') != -1:
self.progressbar.resetTimerAndCounter()
self.progressbar.enable()
elif message.find('maxVal') != -1:
maxVal = int(message.split('=')[-1])
self.progressbar.setMaxValue(maxVal)
elif message.find('newVal') != -1:
newVal = int(message.split('=')[-1])
self.progressbar.setValue(newVal)
def _worker(self):
kwargs = {}
seedpoints, deletepoints = [], []
......@@ -722,6 +759,7 @@ class ParticleDetectionView(QtWidgets.QWidget):
self.seg.setParameters(**kwargs)
measurementPoints, contours= self.seg.apply2Image(self.img, seedpoints, deletepoints, seedradius, self.dataset)
if measurementPoints is None: # computation was canceled
return
......
......@@ -28,8 +28,9 @@ import sys, os
import cv2
from .helperfunctions import cv2imread_fix, cv2imwrite_fix
from time import time
import datetime
import sys
from .opticalbackground import BackGroundManager
from .uielements import TimeEstimateProgressbar
from .zlevelsetter import ZLevelSetter
from .scenePyramid import ScenePyramid
......@@ -338,11 +339,8 @@ class OpticalScan(QtWidgets.QWidget):
self.pexit.released.connect(self.stopScan)
self.prun.setEnabled(False)
self.timelabeltext = "Estimated time to finish: "
self.progressbar = QtWidgets.QProgressBar(self)
self.progresstime = QtWidgets.QLabel(self.timelabeltext, self)
self.progresstime.setEnabled(False)
self.progressbar.setEnabled(False)
self.progressbar = TimeEstimateProgressbar()
self.progressbar.disable()
radioGroup = QtWidgets.QGroupBox('Shape')
radioLayout = QtWidgets.QHBoxLayout()
......@@ -404,7 +402,6 @@ class OpticalScan(QtWidgets.QWidget):
optionsLayout.addLayout(vbox2)
mainLayout.addLayout(optionsLayout)
mainLayout.addWidget(self.progresstime)
mainLayout.addWidget(self.progressbar)
mainLayout.addLayout(btnLayout)
......@@ -441,6 +438,7 @@ class OpticalScan(QtWidgets.QWidget):
QtWidgets.QMessageBox.Yes |
QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No)
if reply == QtWidgets.QMessageBox.Yes:
self.progressbar.resetTimerAndCounter()
self.timer.stop()
self.processstopevent.set()
self.process.join()
......@@ -684,12 +682,9 @@ class OpticalScan(QtWidgets.QWidget):
self.dataqueue, self.processstopevent,
self.logpath, self.hdrcheck.isChecked()))
self.process.start()
self.starttime = time()
self.progresstime.setEnabled(True)
self.progressbar.setEnabled(True)
self.progressbar.setRange(0, len(self.dataset.grid))
self.progressbar.setValue(0)
#self.view.imgdata = None
self.progressbar.enable()
self.progressbar.resetTimerAndCounter()
self.progressbar.setMaxValue(len(self.dataset.grid))
self.pyramid.resetScene()
self.view.blockUI()
grid = np.asarray(self.dataset.grid)
......@@ -734,14 +729,7 @@ class OpticalScan(QtWidgets.QWidget):
removeSrcTiles(names, self.dataset.getScanPath())
self.progressbar.setValue(i+1)
if i > 3:
timerunning = time()-self.starttime
ttot = timerunning*Ngrid/(i+1)
time2go = ttot - timerunning
self.progresstime.setText(self.timelabeltext + str(datetime.timedelta(seconds=round(time2go))))
# reload image in sampleview, calls loadPixmap
# not needed anymore as the scene gets manipulated directly via self.pyramid
# self.imageUpdate.emit(self.view.microscopeMode)
self.imageUpdate.emit(self.view.microscopeMode)
if i==Ngrid-1:
# cv2imwrite_fix(self.dataset.getImageName(), cv2.cvtColor(self.view.imgdata, cv2.COLOR_RGB2BGR))
......@@ -756,9 +744,8 @@ class OpticalScan(QtWidgets.QWidget):
self.view.saveDataSet()
self.view.unblockUI()
self.view.switchMode("ParticleDetection")
self.progressbar.setValue(0)
self.progressbar.setEnabled(False)
self.progresstime.setEnabled(False)
self.progressbar.resetTimerAndCounter()
self.progressbar.disable()
self.close()
return
self.timer.start(100.)
......
......@@ -24,10 +24,10 @@ import numpy as np
from multiprocessing import Process, Queue, Event
import queue
from time import time
from .external import tsp
import datetime
import sys
import os
from .external import tsp
from .uielements import TimeEstimateProgressbar
def reorder(points, N=20):
y0, y1 = points[:,1].min(), points[:,1].max()
......@@ -110,18 +110,15 @@ class RamanScanUI(QtWidgets.QWidget):
self.pexit = QtWidgets.QPushButton("Cancel", self)
self.pexit.released.connect(self.stopScan)
self.prun.setEnabled(False)
self.progressbar = QtWidgets.QProgressBar(self)
self.timelabeltext = "Estimated time to finish: "
self.progresstime = QtWidgets.QLabel(self.timelabeltext, self)
self.progresstime.setEnabled(False)
self.progressbar.setEnabled(False)
self.progressbar = TimeEstimateProgressbar()
self.progressbar.disable()
hbox.addStretch()
hbox.addWidget(self.pexit)
vbox.addWidget(self.paramsGroup)
vbox.addLayout(hbox)
vbox.addWidget(self.progresstime)
vbox.addWidget(self.progressbar)
self.setLayout(vbox)
......@@ -189,6 +186,7 @@ class RamanScanUI(QtWidgets.QWidget):
QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No)
if reply == QtWidgets.QMessageBox.Yes:
self.timer.stop()
self.progressbar.resetTimerAndCounter()
self.processstopevent.set()
self.process.join()
self.dataqueue.close()
......@@ -264,10 +262,9 @@ class RamanScanUI(QtWidgets.QWidget):
self.view.highLightRamanIndex(0)
self.view.blockUI()
self.paramsGroup.setEnabled(False)
self.progresstime.setEnabled(True)
self.progressbar.setEnabled(True)
self.progressbar.setRange(0, len(scanpoints))
self.progressbar.setValue(0)
self.progressbar.enable()
self.progressbar.resetTimerAndCounter()
self.progressbar.setMaxValue(len(scanpoints))
self.ramanctrl.disconnect()
self.processstopevent = Event()
self.dataqueue = Queue()
......@@ -295,11 +292,6 @@ class RamanScanUI(QtWidgets.QWidget):
self.view.highLightRamanIndex(i+1) #go to next scanmarker
Npoints = len(self.dataset.particleContainer.getMeasurementPixelCoords())
if i>3:
timerunning = time()-self.starttime
ttot = timerunning*Npoints/(i+1)
time2go = ttot - timerunning
self.progresstime.setText(self.timelabeltext + str(datetime.timedelta(seconds=round(time2go))))
if i==Npoints-1:
self.process.join()
self.dataqueue.close()
......@@ -307,9 +299,8 @@ class RamanScanUI(QtWidgets.QWidget):
self.dataset.ramanscandone = True
self.view.saveDataSet()
self.view.unblockUI()
self.progressbar.setValue(0)
self.progressbar.setEnabled(False)
self.progresstime.setEnabled(False)
self.progressbar.resetTimerAndCounter()
self.progressbar.disable()
self.close()
return
self.timer.start(100.)
......
......@@ -28,6 +28,8 @@ from skimage.feature import peak_local_max
from skimage.morphology import watershed
import skfuzzy as fuzz
import random
from PyQt5 import QtCore
def closeHolesOfSubImage(subimg):
subimg = cv2.copyMakeBorder(subimg, 1, 1, 1, 1, 0)
......@@ -63,8 +65,10 @@ class MeasurementPoint(object):
self.x = x
self.y = y
class Segmentation(object):
class Segmentation(QtCore.QObject):
detectionState = QtCore.pyqtSignal(str)
def __init__(self, dataset=None, parent=None):
super(Segmentation, self).__init__()
self.cancelcomputation = False
self.parent = parent
self.defaultParams = {'adaptiveHistEqu': False,
......@@ -84,6 +88,7 @@ class Segmentation(object):
'minparticledistance': 20,
'closeBackground': False,
'fuzzycluster': False,
'maxComponentSize': 20000,
'measurefrac': 1,
'compactness': 0.0,
'seedRad': 3}
......@@ -115,6 +120,7 @@ class Segmentation(object):
Parameter("measurefrac", float, self.detectParams['measurefrac'], 0, 1, 2, stepsize = 0.05, helptext="measure fraction of particles", show=False),
Parameter("closeBackground", np.bool, self.detectParams['closeBackground'], helptext="close holes in sure background", show=False),
Parameter("fuzzycluster", np.bool, self.detectParams['fuzzycluster'], helptext='Enable Fuzzy Clustering', show=False),
Parameter("maxComponentSize", int, self.detectParams['maxComponentSize'], 100, 1E6, 0, 100, helptext='Maximum size in x or y of connected component.\nLarger components are scaled down accordingly', show=False),
Parameter("sure_fg", None, helptext="Show sure foreground", show=True),
Parameter("compactness", float, self.detectParams['compactness'], 0, 1, 2, 0.05, helptext="watershed compactness", show=False),
Parameter("watershed", None, helptext="Show watershed markers", show=True),
......@@ -140,8 +146,10 @@ class Segmentation(object):
:return:
"""
t0 = time()
self.detectionState.emit('DO: setup')
gray = self.convert2Gray(img)
self.detectionState.emit('finished GrayScale')
print("gray")
if self.adaptiveHistEqu:
......@@ -149,6 +157,7 @@ class Segmentation(object):
numTilesY = round(img.shape[0]/self.claheTileSize)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(numTilesY,numTilesX))
gray = clahe.apply(gray)
self.detectionState.emit('finished CLAHE')
if return_step=="claheTileSize": return gray, 0
print("adaptive Histogram Adjustment")
......@@ -159,6 +168,7 @@ class Segmentation(object):
xi, arr = self.calculateHistFunction(self.contrastCurve)
gray = arr[gray]
print("contrast curve")
self.detectionState.emit('finished Contrast Curve')
if self.cancelcomputation:
return None, None
......@@ -171,6 +181,7 @@ class Segmentation(object):
del gray
if return_step=="blurRadius": return blur, 0
self.detectionState.emit('finished Blurring')
print("blur")
if self.cancelcomputation:
return None, None
......@@ -215,6 +226,7 @@ class Segmentation(object):
thresh = self.closeBrightHoles(thresh, blur, self.maxholebrightness)
del blur
print("thresholded")
self.detectionState.emit('finished thresholding')
# modify thresh with seedpoints and deletepoints
for p in np.int32(seedpoints):
......@@ -235,6 +247,8 @@ class Segmentation(object):
'''the peak_local_max function takes the min distance between peaks. Unfortunately, that means that individual
particles smaller than that distance are consequently disregarded. Hence, we need a connectec_components approach'''
n, labels, stats, centroids = cv2.connectedComponentsWithStats(thresh, 8, cv2.CV_32S)
self.detectionState.emit('finished connected components search')
self.detectionState.emit(f'DO: maxVal={n-1}')
del thresh
measurementPoints = {}
......@@ -249,7 +263,6 @@ class Segmentation(object):
else:
previewImage = np.zeros(img.shape[:2])
for label in range(1, n):
area = stats[label, cv2.CC_STAT_AREA]
if self.minparticlearea < area < maxArea:
......@@ -257,26 +270,35 @@ class Segmentation(object):
left = stats[label, cv2.CC_STAT_LEFT]
width = stats[label, cv2.CC_STAT_WIDTH]
height = stats[label, cv2.CC_STAT_HEIGHT]
# if width > 25000 or height > 25000:
if False:
print(f'skipping{label} of {n} compontents, too large: {width} x {height} pixel!!!')
else:
print(f'processing {label} of {n} compontents, {width} x {height} pixel')
subthresh = np.uint8(255 * (labels[up:(up+height), left:(left+width)] == label))
scaleFactor = 1.0
if width > self.maxComponentSize or height > self.maxComponentSize:
scaleFactor = max([width/self.maxComponentSize, height/self.maxComponentSize])
subthresh = cv2.resize(subthresh, None, fx=1/scaleFactor, fy=1/scaleFactor)
subdist = cv2.distanceTransform(subthresh, cv2.DIST_L2, 3)
sure_fg = self.getSureForeground(subthresh, subdist, self.minparticledistance)
minDistance = round(self.minparticledistance / scaleFactor)
sure_fg = self.getSureForeground(subthresh, subdist, minDistance)
sure_bg = cv2.dilate(subthresh, np.ones((5, 5)), iterations = 1)
if self.closeBackground:
sure_bg = self.closeHoles(sure_bg)
# modify sure_fg and sure_bg with seedpoints and deletepoints
for p in np.int32(seedpoints):
cv2.circle(sure_fg, tuple([p[0]-left, p[1]-up]), int(p[2]), 1, -1)
cv2.circle(sure_bg, tuple([p[0]-left, p[1]-up]), int(p[2]), 1, -1)
x = int(round(p[0] / scaleFactor)-left)
y = int(round(p[1] / scaleFactor) - up)
radius = int(round(p[2] / scaleFactor))
cv2.circle(sure_fg, (x, y), radius, 1, -1)
cv2.circle(sure_bg, (x, y), radius, 1, -1)
for p in np.int32(deletepoints):
cv2.circle(sure_fg, tuple([p[0]-left, p[1]-up]), int(p[2]), 0, -1)
cv2.circle(sure_bg, tuple([p[0]-left, p[1]-up]), int(p[2]), 0, -1)
x = int(round(p[0] / scaleFactor) - left)
y = int(round(p[1] / scaleFactor) - up)
radius = int(round(p[2] / scaleFactor))
cv2.circle(sure_fg, (x, y), radius, 1, -1)
cv2.circle(sure_bg, (x, y), radius, 1, -1)
if self.cancelcomputation:
return None, None
......@@ -293,7 +315,11 @@ class Segmentation(object):
markers[unknown==255] = 0
markers = ndi.label(sure_fg)[0]
try:
markers = watershed(-subdist, markers, mask=sure_bg, compactness = self.compactness, watershed_line = True) #labels = 0 for background, 1... for particles
except MemoryError:
self.parent.raiseWarning('Segmentation failed due to large connected components.\nPlease reduce maximal connected Component Size.')
return None, None
if self.cancelcomputation:
return None, None
......@@ -312,17 +338,28 @@ class Segmentation(object):
tmpcontours = [contours[i] for i in range(len(contours)) if hierarchy[0,i,3]<0]
for cnt in tmpcontours:
if cv2.contourArea(cnt) >= self.minparticlearea:
label = markers[cnt[0,0,1],cnt[0,0,0]]
if label==0:
contourArea = cv2.contourArea(cnt) * scaleFactor**2
if contourArea >= self.minparticlearea:
tmplabel = markers[cnt[0,0,1],cnt[0,0,0]]
if tmplabel ==0:
continue
x0, x1 = cnt[:,0,0].min(), cnt[:,0,0].max()
y0, y1 = cnt[:,0,1].min(), cnt[:,0,1].max()
subimg = (markers[y0:y1+1,x0:x1+1]).copy()
subimg[subimg!=label] = 0
subimg[subimg!=tmplabel ] = 0
y, x = self.getMeasurementPoints(subimg)
if scaleFactor != 1:
x0 = int(round(x0 * scaleFactor))
y0 = int(round(y0 * scaleFactor))
x = [int(round(subX * scaleFactor)) for subX in x]
y = [int(round(subY * scaleFactor)) for subY in y]
for i in range(len(cnt)):
cnt[i][0][0] = int(round(cnt[i][0][0] * scaleFactor))
cnt[i][0][1] = int(round(cnt[i][0][1] * scaleFactor))
for i in range(len(cnt)):
cnt[i][0][0] += left
cnt[i][0][1] += up
......@@ -333,8 +370,11 @@ class Segmentation(object):
for index in range(0, len(x)):
newMeasPoint = MeasurementPoint(particleIndex, x[index] + x0 + left, y[index] + y0 + up)
measurementPoints[particleIndex].append(newMeasPoint)
particleIndex += 1
self.detectionState.emit(f'DO: newVal={label}')
if return_step == 'sure_fg':
img = np.zeros_like(preview_surefg)
img[np.nonzero(preview_surefg)] |= 1
......@@ -360,6 +400,8 @@ class Segmentation(object):
total_time = time()-t0
print('segmentation took', total_time, 'seconds')
total_time = round(total_time, 2)
self.detectionState.emit(f'finished particle detection after {total_time} seconds')
return measurementPoints, finalcontours
......
#!/usr/bin/env python3
# -*- 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 QtWidgets
from time import time
import datetime
class TimeEstimateProgressbar(QtWidgets.QGroupBox):
"""
A progressbar with time estimate for computationally expensive tasks. It contains the actual progressbar together
with a text label that is updated accordingly. It also estimates the remaining progress time.
Call enable and disable-methods for optically enabling and disabling the widgets.
Call resetTimerAndCounter before the process starts,
Then set the maxValue using the setMaxValue method.
Finally, for pushing an update, use the setValue method. It will update the text label as well.
Furthermore, a text message can be pushed to the text label at any time, using the setMessage method.
:return:
"""
def __init__(self):
super(TimeEstimateProgressbar, self).__init__()
layout = QtWidgets.QVBoxLayout()
self.setLayout(layout)
self.timelabeltext = "Estimated time to finish: - "
self.progressbar = QtWidgets.QProgressBar(self)
self.progresstime = QtWidgets.QLabel(self.timelabeltext, self)
self.progresstime.setEnabled(False)
self.progressbar.setEnabled(False)
self.maxVal = None
self.startTime = None
layout.addWidget(self.progresstime)
layout.addWidget(self.progressbar)
def setMaxValue(self, maxVal):
"""
Sets the maximal value of the progressbar. Also needed for estimating remaining time.
:return:
"""
self.maxVal = maxVal
self.progressbar.setRange(0, self.maxVal)
def enable(self):
"""
Enables the UI widgets.
:return:
"""
self.progressbar.setEnabled(True)
self.progresstime.setEnabled(True)
def disable(self):
"""
Disables the UI widgets.
:return:
"""
self.progressbar.setEnabled(False)
self.progresstime.setEnabled(False)
def resetTimerAndCounter(self):
"""
Resets the timer and sets the progressbar to value 0
:return:
"""
self.startTime = time()
self.progressbar.setValue(0)
def setMessage(self, msg):
"""
Pushes a message to the progressbar's textlabel.
:return:
"""
self.progresstime.setText(msg)
def setValue(self, newValue):
"""
Sets the value of the progressbar and calls updating the TimeLabel
:return:
"""
self.progressbar.setValue(newValue)
self._updatetTimeLabel()
def _updatetTimeLabel(self):
"""
Calculates remaining Time and updates the label.
:return:
"""
if self.progressbar.value() > 3:
timerunning = time()-self.startTime
ttot = timerunning*self.maxVal/(self.progressbar.value()+1)
time2go = ttot - timerunning
self.progresstime.setText(self.timelabeltext + str(datetime.timedelta(seconds=round(time2go))))
\ No newline at end of file
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment