class EchoWidget(ToolWidget): def __init__(self, image, parent=None): super(EchoWidget, self).__init__(parent) self.radius_spin = QSpinBox() self.radius_spin.setRange(1, 15) self.radius_spin.setSuffix(self.tr(' px')) self.radius_spin.setValue(2) self.radius_spin.setToolTip(self.tr('Laplacian filter radius')) self.contrast_spin = QSpinBox() self.contrast_spin.setRange(0, 100) self.contrast_spin.setSuffix(self.tr(' %')) self.contrast_spin.setValue(85) self.contrast_spin.setToolTip(self.tr('Output tonality compression')) self.gray_check = QCheckBox(self.tr('Grayscale')) self.gray_check.setToolTip(self.tr('Desaturated output mode')) self.image = image self.viewer = ImageViewer(self.image, self.image, None) self.process() self.radius_spin.valueChanged.connect(self.process) self.contrast_spin.valueChanged.connect(self.process) self.gray_check.stateChanged.connect(self.process) params_layout = QHBoxLayout() params_layout.addWidget(QLabel(self.tr('Radius:'))) params_layout.addWidget(self.radius_spin) params_layout.addWidget(QLabel(self.tr('Contrast:'))) params_layout.addWidget(self.contrast_spin) params_layout.addWidget(self.gray_check) params_layout.addStretch() main_layout = QVBoxLayout() main_layout.addLayout(params_layout) main_layout.addWidget(self.viewer) self.setLayout(main_layout) def process(self): start = time() kernel = 2 * self.radius_spin.value() + 1 contrast = int(self.contrast_spin.value() / 100 * 255) lut = create_lut(0, contrast) laplace = [] for channel in cv.split(self.image): deriv = np.fabs(cv.Laplacian(channel, cv.CV_64F, None, kernel)) deriv = cv.normalize(deriv, None, 0, 255, cv.NORM_MINMAX, cv.CV_8UC1) laplace.append(cv.LUT(deriv, lut)) result = cv.merge(laplace) if self.gray_check.isChecked(): result = bgr_to_gray3(result) self.viewer.update_processed(result) self.info_message.emit('Echo Edge Filter = {}'.format( elapsed_time(start)))
class ElaWidget(ToolWidget): def __init__(self, image, parent=None): super(ElaWidget, self).__init__(parent) self.quality_spin = QSpinBox() self.quality_spin.setRange(0, 100) self.quality_spin.setSuffix(self.tr(' %')) self.quality_spin.setToolTip(self.tr('JPEG reference quality level')) self.scale_spin = QSpinBox() self.scale_spin.setRange(1, 100) self.scale_spin.setSuffix(' %') self.scale_spin.setToolTip(self.tr('Output multiplicative gain')) self.contrast_spin = QSpinBox() self.contrast_spin.setRange(0, 100) self.contrast_spin.setSuffix(' %') self.contrast_spin.setToolTip(self.tr('Output tonality compression')) self.equalize_check = QCheckBox(self.tr('Equalized')) self.equalize_check.setToolTip(self.tr('Apply histogram equalization')) self.gray_check = QCheckBox(self.tr('Grayscale')) self.gray_check.setToolTip(self.tr('Desaturated output')) default_button = QPushButton(self.tr('Default')) default_button.setToolTip(self.tr('Revert to default parameters')) params_layout = QHBoxLayout() params_layout.addWidget(QLabel(self.tr('Quality:'))) params_layout.addWidget(self.quality_spin) params_layout.addWidget(QLabel(self.tr('Scale:'))) params_layout.addWidget(self.scale_spin) params_layout.addWidget(QLabel(self.tr('Contrast:'))) params_layout.addWidget(self.contrast_spin) params_layout.addWidget(self.equalize_check) params_layout.addWidget(self.gray_check) params_layout.addWidget(default_button) params_layout.addStretch() self.image = image self.original = image.astype(np.float32) / 255 self.viewer = ImageViewer(self.image, self.image) self.default() self.quality_spin.valueChanged.connect(self.process) self.scale_spin.valueChanged.connect(self.process) self.contrast_spin.valueChanged.connect(self.process) self.equalize_check.stateChanged.connect(self.process) self.gray_check.stateChanged.connect(self.process) default_button.clicked.connect(self.default) main_layout = QVBoxLayout() main_layout.addLayout(params_layout) main_layout.addWidget(self.viewer) self.setLayout(main_layout) def process(self): start = time() quality = self.quality_spin.value() scale = self.scale_spin.value() / 20 contrast = int(self.contrast_spin.value() / 100 * 128) equalize = self.equalize_check.isChecked() grayscale = self.gray_check.isChecked() self.scale_spin.setEnabled(not equalize) self.contrast_spin.setEnabled(not equalize) compressed = compress_img(self.image, quality).astype(np.float32) / 255 difference = cv.absdiff(self.original, compressed) if equalize: ela = equalize_img((difference * 255).astype(np.uint8)) else: ela = cv.convertScaleAbs(cv.sqrt(difference) * 255, None, scale) ela = cv.LUT(ela, create_lut(contrast, contrast)) if grayscale: ela = desaturate(ela) self.viewer.update_processed(ela) self.info_message.emit( self.tr('Error Level Analysis = {}'.format(elapsed_time(start)))) def default(self): self.blockSignals(True) self.equalize_check.setChecked(False) self.gray_check.setChecked(False) self.quality_spin.setValue(75) self.scale_spin.setValue(50) self.contrast_spin.setValue(20) self.process() self.blockSignals(False)
class MagnifierWidget(ToolWidget): def __init__(self, image, parent=None): super(MagnifierWidget, self).__init__(parent) self.equalize_radio = QRadioButton(self.tr("Equalization")) self.equalize_radio.setToolTip(self.tr("RGB histogram equalization")) self.contrast_radio = QRadioButton(self.tr("Auto Contrast")) self.contrast_radio.setToolTip(self.tr("Compress luminance tonality")) self.centile_spin = QSpinBox() self.centile_spin.setRange(0, 100) self.centile_spin.setValue(20) self.centile_spin.setSuffix(self.tr(" %")) self.centile_spin.setToolTip(self.tr("Histogram percentile amount")) self.channel_check = QCheckBox(self.tr("By channel")) self.channel_check.setToolTip(self.tr("Independent RGB compression")) self.equalize_radio.setChecked(True) self.last_radio = self.equalize_radio self.image = image self.viewer = ImageViewer(self.image, self.image) self.change() self.viewer.viewChanged.connect(self.process) self.equalize_radio.clicked.connect(self.change) self.contrast_radio.clicked.connect(self.change) self.centile_spin.valueChanged.connect(self.change) self.channel_check.stateChanged.connect(self.change) top_layout = QHBoxLayout() top_layout.addWidget(QLabel(self.tr("Mode:"))) top_layout.addWidget(self.equalize_radio) top_layout.addWidget(self.contrast_radio) top_layout.addWidget(self.centile_spin) top_layout.addWidget(self.channel_check) top_layout.addStretch() main_layout = QVBoxLayout() main_layout.addLayout(top_layout) main_layout.addWidget(self.viewer) self.setLayout(main_layout) def process(self, rect): y1 = rect.top() y2 = rect.bottom() x1 = rect.left() x2 = rect.right() roi = self.image[y1:y2, x1:x2] if self.equalize_radio.isChecked(): self.centile_spin.setEnabled(False) self.channel_check.setEnabled(False) roi = equalize_img(roi) self.last_radio = self.equalize_radio elif self.contrast_radio.isChecked(): self.centile_spin.setEnabled(True) self.channel_check.setEnabled(True) centile = self.centile_spin.value() / 200 if self.channel_check.isChecked(): roi = cv.merge( [cv.LUT(c, auto_lut(c, centile)) for c in cv.split(roi)]) else: roi = cv.LUT( roi, auto_lut(cv.cvtColor(roi, cv.COLOR_BGR2GRAY), centile)) self.last_radio = self.contrast_radio else: self.last_radio.setChecked(True) return processed = np.copy(self.image) processed[y1:y2, x1:x2] = roi self.viewer.update_processed(processed) def change(self): self.process(self.viewer.get_rect())
class main(QWidget): def __init__(self, parent=None): super(main, self).__init__(parent) self.setup() # connections, widgets, layouts, etc. self.blksize = 2**20 # 1 MB; must be divisible by 16 self.ext = '.enc' # extension is appended to encrypted files self.path = '' self.encrypted = [] # to highlight done files in list self.decrypted = [] self.clipboard = QApplication.clipboard() self.timeout = None # to clear message label, see setMessage # this program was just an excuse to play with QprogressBar if not hash(os.urandom(11)) % 11: QTimer().singleShot(50, self.windDown) # various random hints hints = [ 'Freshly encrypted files can be renamed in the table!', 'Clipboard is always cleared on program close!', 'Keys can contain emoji if you <em>really</em> want: \U0001f4e6', 'Keys can contain emoji if you <em>really</em> want: \U0001F511', 'This isn\'t a tip, I just wanted to say hello!', 'Keys can be anywhere from 8 to 4096 characters long!', 'This program was just an excuse to play with the progress bars!', 'Select \'Party\' in the hash button for progress bar fun!', ('Did you know you can donate one or all of your vital organs to ' 'the Aperture Science Self-Esteem Fund for Girls? It\'s true!'), ('It\'s been {:,} days since Half-Life 2: Episode ' 'Two'.format(int((time.time() - 1191988800) / 86400))), 'I\'m version {}!'.format(VERSION), 'I\'m version {}.whatever!'.format(VERSION.split('.')[0]), ('Brought to you by me, I\'m <a href="https://orthallelous.word' 'press.com/">Orthallelous!</a>'), #'Brought to you by me, I\'m Htom Sirveaux!', 'I wonder if there\'s beer on the sun', 'Raspberry World: For all your raspberry needs. Off the beltline', #'I\'ve plummented to my death and I can\'t get up', '<em>NOT</em> compatible with the older version!', ('Hello there, fellow space travellers! Until somebody gives me ' 'some new lines in KAS, that is all I can say. - Bentusi Exchange' ) ] if not hash(os.urandom(9)) % 4: self.extraLabel.setText(random.choice(hints)) def genKey(self): "generate a random key" n = self.keySizeSB.value() char = string.printable.rstrip() #map(chr, range(256)) while len(char) < n: char += char key = ''.join(random.sample(char, n)) self.keyInput.setText(key) def showKey(self, state=None): "hide/show key characters" if state is None: state = bool(self.showKeyCB.checkState()) else: state = bool(state) if state: self.keyInput.setEchoMode(QLineEdit.Normal) else: self.keyInput.setEchoMode(QLineEdit.PasswordEchoOnEdit) def getFolder(self): "open file dialog and fill file table" path = QFileDialog(directory=self.path).getExistingDirectory() if not path: return self.path = str(path) self.populateTable(self.path) self.encrypted, self.decrypted = [], [] return def resizeEvent(self, event): self.showFolder(self.path) # update how the folder is shown def splitterChanged(self, pos): self.showFolder(self.path) # likewise def showFolder(self, path): "displays current path, truncating as needed" if not path: return ell, sl = '\u2026', os.path.sep # ellipsis, slash chars lfg, rfg = Qt.ElideLeft, Qt.ElideRight lst, wdh = os.path.basename(path), self.folderLabel.width() path = path.replace(os.path.altsep or '\\', sl) self.folderLabel.setToolTip(path) # truncate folder location fnt = QFontMetrics(self.folderLabel.font()) txt = str(fnt.elidedText(path, lfg, wdh)) if len(txt) <= 1: # label is way too short self.folderLabel.setText('\u22ee' if txt != sl else txt) return # but when would this happen? # truncate some more (don't show part of a folder name) if len(txt) < len(path) and txt[1] != sl: txt = ell + sl + txt.split(sl, 1)[-1] # don't truncate remaining folder name from the left if txt[2:] != lst and len(txt[2:]) < len(lst) + 2: txt = str(fnt.elidedText(ell + sl + lst, rfg, wdh)) # you'd think len(txt) < len(lst) would work, but no; you'd be wrong self.folderLabel.setText(txt) def populateTable(self, path): "fill file table with file names" self.showFolder(path) names = [] for n in os.listdir(path): if os.path.isdir(os.path.join(path, n)): continue # folder names.append(n) self.folderTable.clearContents() self.folderTable.setRowCount(len(names)) self.folderTable.setColumnCount(1) if not names: # no files in this folder, inform user self.setMessage('This folder has no files') return self.folderTable.blockSignals(True) selEnab = Qt.ItemIsSelectable | Qt.ItemIsEnabled for i, n in enumerate(names): item = QTableWidgetItem() item.setText(n) item.setToolTip(n) item.setFlags(selEnab) # color code encrypted/decrypted files if n in self.encrypted: item.setTextColor(QColor(211, 70, 0)) # allowed encrypted filenames to be changed item.setFlags(selEnab | Qt.ItemIsEditable) if n in self.decrypted: item.setForeground(QColor(0, 170, 255)) self.folderTable.setItem(i, 0, item) if len(names) > 5: self.setMessage('{:,} files'.format(len(names)), 7) self.folderTable.blockSignals(False) return def editFileName(self, item): "change file name" new, old = str(item.text()), str(item.toolTip()) result = QMessageBox.question( self, 'Renaming?', ("<p align='center'>Do you wish to rename<br>" + '<span style="color:#d34600;">{}</span>'.format(old) + "<br>to<br>" + '<span style="color:#ef4b00;">{}</span>'.format(new) + '<br>?</p>')) self.folderTable.blockSignals(True) if any(i in new for i in '/?<>:*|"^'): self.setMessage('Invalid character in name', 7) item.setText(old) elif result == QMessageBox.Yes: oold = os.path.join(self.path, old) try: os.rename(oold, os.path.join(self.path, new)) self.encrypted.remove(old) self.encrypted.append(new) item.setToolTip(new) except Exception as err: self.setMessage(str(err), 9) item.setText(old) item.setToolTip(old) self.encrypted.remove(new) self.encrypted.append(old) else: item.setText(old) self.folderTable.blockSignals(False) def setMessage(self, message, secs=4, col=None): "show a message for a few seconds - col must be rgb triplet tuple" if self.timeout: # https://stackoverflow.com/a/21081371 self.timeout.stop() self.timeout.deleteLater() if col is None: color = 'rgb(255, 170, 127)' else: try: color = 'rgb({}, {}, {})'.format(*col) except: color = 'rgb(255, 170, 127)' self.messageLabel.setStyleSheet('background-color: {};'.format(color)) self.messageLabel.setText(message) self.messageLabel.setToolTip(message) self.timeout = QTimer() self.timeout.timeout.connect(self.clearMessage) self.timeout.setSingleShot(True) self.timeout.start(secs * 1000) def clearMessage(self): self.messageLabel.setStyleSheet('') self.messageLabel.setToolTip('') self.messageLabel.setText('') def getName(self): "return file name of selected" items = self.folderTable.selectedItems() names = [str(i.text()) for i in items] if names: return names[0] # only the first selected file else: return '' def showKeyLen(self, string): "displays a tooltip showing length of key" s = len(string) note = '{:,} character{}'.format(s, '' if s == 1 else 's') tip = QToolTip pos = self.genKeyButton.mapToGlobal(QPoint(0, 0)) if s < self.minKeyLen: note = '<span style="color:#c80000;">{}</span>'.format(note) else: note = '<span style="color:#258f22;">{}</span>'.format(note) tip.showText(pos, note) def lock(self, flag=True): "locks buttons if True" stuff = [ self.openButton, self.encryptButton, self.decryptButton, self.genKeyButton, self.hashButton, self.showKeyCB, self.copyButton, self.keyInput, self.keySizeSB, self.folderTable, ] for i in stuff: i.blockSignals(flag) i.setEnabled(not flag) return def _lerp(self, v1, v2, numPts=10): "linearly interpolate from v1 to v2\nFrom Orthallelous" if len(v1) != len(v2): raise ValueError("different dimensions") D, V, n = [], [], abs(numPts) for i, u in enumerate(v1): D.append(v2[i] - u) for i in range(n + 1): vn = [] for j, u in enumerate(v1): vn.append(u + D[j] / float(n + 2) * i) V.append(tuple(vn)) return V def weeeeeee(self): "party time" self.lock() self.setMessage('Party time!', 2.5) a, b, c = self.encryptPbar, self.decryptPbar, self.hashPbar process, sleep = app.processEvents, time.sleep am, bm, cm = a.minimum(), b.minimum(), c.minimum() ax, bx, cx = a.maximum(), b.maximum(), c.maximum() a.reset() b.reset() c.reset() loops = self._lerp((am, bm, cm), (ax, bx, cx), 100) ivops = loops[::-1] # up and up! for i in range(3): for j, k, l in loops: a.setValue(int(j)) b.setValue(int(k)) c.setValue(int(l)) process() sleep(0.01) a.setValue(ax) b.setValue(bx) c.setValue(cx) sleep(0.25) a.setValue(am) b.setValue(bm) c.setValue(cm) # snake! self.setMessage('Snake time!') self.messageLabel.setStyleSheet('background-color: rgb(127,170,255);') for i in range(2): for j, k, l in loops: a.setValue(int(j)) process() sleep(0.002) process() a.setInvertedAppearance(True) process() for j, k, l in ivops: a.setValue(int(j)) process() sleep(0.002) for j, k, l in loops: b.setValue(int(k)) process() sleep(0.002) process() b.setInvertedAppearance(False) process() for j, k, l in ivops: b.setValue(int(k)) process() sleep(0.002) for j, k, l in loops: c.setValue(int(l)) process() sleep(0.002) process() c.setInvertedAppearance(True) process() for j, k, l in ivops: c.setValue(int(l)) process() sleep(0.002) process() b.setInvertedAppearance(True) process() for j, k, l in loops: b.setValue(int(k)) process() sleep(0.002) process() b.setInvertedAppearance(False) process() for j, k, l in ivops: b.setValue(int(k)) process() sleep(0.002) process() a.setInvertedAppearance(False) b.setInvertedAppearance(True) c.setInvertedAppearance(False) for j, k, l in loops: a.setValue(int(j)) process() sleep(0.002) process() a.setInvertedAppearance(True) process() for j, k, l in ivops: a.setValue(int(j)) process() sleep(0.002) # bars sleep(0.5) self.setMessage('Bars!') process() self.messageLabel.setStyleSheet('background-color: rgb(127,255,170);') for i in range(2): a.setValue(ax) time.sleep(0.65) a.setValue(am) sleep(0.25) process() b.setValue(bx) time.sleep(0.65) b.setValue(bm) sleep(0.25) process() c.setValue(cx) time.sleep(0.65) c.setValue(cm) sleep(0.25) process() b.setValue(bx) time.sleep(0.65) b.setValue(bm) sleep(0.25) process() # okay, enough process() a.setValue(ax) b.setValue(bx) c.setValue(cx) #a.setValue(am); b.setValue(bm); c.setValue(cm) a.setInvertedAppearance(False) b.setInvertedAppearance(True) c.setInvertedAppearance(False) self.lock(False) return def windDown(self, note=None): "silly deload on load" if note is None: note = 'Loading...' self.lock() self.setMessage(note) self.messageLabel.setStyleSheet('background-color: rgb(9, 190, 130);') a, b, c = self.encryptPbar, self.decryptPbar, self.hashPbar am, bm, cm = a.minimum(), b.minimum(), c.minimum() ax, bx, cx = a.maximum(), b.maximum(), c.maximum() a.reset() b.reset() c.reset() loops = self._lerp((ax, bx, cx), (am, bm, cm), 100) for j, k, l in loops: a.setValue(int(j)) b.setValue(int(k)) c.setValue(int(l)) app.processEvents() time.sleep(0.02) a.reset() b.reset() c.reset() self.lock(False) self.clearMessage() def genHash(self, action): "generate hash of selected file and display it" name, t0 = self.getName(), time.perf_counter() # mark what hash was used in the drop-down menu for i in self.hashButton.menu().actions(): if i == action: i.setIconVisibleInMenu(True) else: i.setIconVisibleInMenu(False) if str(action.text()) == 'Party': self.weeeeeee() self.windDown('Winding down...') return if not name: self.setMessage('No file selected') return if not os.path.exists(os.path.join(self.path, name)): self.setMessage('File does not exist') return self.lock() hsh = self.hashFile(os.path.join(self.path, name), getattr(hashlib, str(action.text()))) self.lock(False) #hsh = str(action.text()) + ': ' + hsh self.hashLabel.setText(hsh) self.hashLabel.setToolTip(hsh) self.extraLabel.setText( str(action.text()) + ' hash took ' + self.secs_fmt(time.perf_counter() - t0)) def setCancel(self): "cancel operation" self._requestStop = True def showCancelButton(self, state=False): "show/hide cancel button" self.cancelButton.blockSignals(not state) self.cancelButton.setEnabled(state) if state: self.cancelButton.show() self.keyInput.hide() self.genKeyButton.hide() self.keySizeSB.hide() else: self.cancelButton.hide() self.keyInput.show() self.genKeyButton.show() self.keySizeSB.show() def hashFile(self, fn, hasher): "returns the hash value of a file" hsh, blksize = hasher(), self.blksize fsz, csz = os.path.getsize(fn), 0.0 self.hashPbar.reset() self.showCancelButton(True) prog, title = '(# {:.02%}) {}', self.windowTitle() with open(fn, 'rb') as f: while 1: blk = f.read(blksize) if not blk: break hsh.update(blk) csz += blksize self.hashPbar.setValue(int(round(csz * 100.0 / fsz))) app.processEvents() self.setWindowTitle(prog.format(csz / fsz, title)) if self._requestStop: break self.hashPbar.setValue(self.hashPbar.maximum()) self.setWindowTitle(title) self.showCancelButton(False) if self._requestStop: self.setMessage('Hashing canceled!') self.hashPbar.setValue(self.hashPbar.minimum()) self._requestStop = False return return hsh.hexdigest() def hashKey(self, key, salt=b''): "hashes a key for encrypting/decrypting file" salt = salt.encode() if type(salt) != bytes else salt key = key.encode() if type(key) != bytes else key p = app.processEvents self.setMessage('Key Hashing...', col=(226, 182, 249)) p() key = hashlib.pbkdf2_hmac('sha512', key, salt, 444401) p() self.clearMessage() p() return hashlib.sha3_256(key).digest() # AES requires a 32 char key def encrypt(self): "encrypt selected file with key" name, t0 = self.getName(), time.perf_counter() if not name: self.setMessage('No file selected') return if not os.path.exists(os.path.join(self.path, name)): self.setMessage('File does not exist') return key = str(self.keyInput.text()) if len(key) < self.minKeyLen: self.setMessage(('Key must be at least ' '{} characters long').format(self.minKeyLen)) return self.lock() gn = self.encryptFile(key, os.path.join(self.path, name)) if not gn: self.lock(False) return self.encrypted.append(os.path.basename(gn)) self.lock(False) self.populateTable(self.path) # repopulate folder list bn, tt = os.path.basename(gn), time.perf_counter() - t0 self.setMessage('Encrypted, saved "{}"'.format(bn, 13)) self.extraLabel.setText('Encrypting took ' + self.secs_fmt(tt)) def encryptFile(self, key, fn): "encrypts a file using AES (MODE_GCM)" chars = ''.join(map(chr, range(256))).encode() chk = AES.block_size sample = random.sample iv = bytes(sample(chars, chk * 2)) salt = bytes(sample(chars * 2, 256)) vault = AES.new(self.hashKey(key, salt), AES.MODE_GCM, iv) fsz = os.path.getsize(fn) del key blksize = self.blksize gn = fn + self.ext fne = os.path.basename(fn).encode() fnz = len(fne) if len(fne) % chk: fne += bytes(sample(chars, chk - len(fne) % chk)) csz = 0.0 # current processed value self.encryptPbar.reset() prog, title = '({:.02%}) {}', self.windowTitle() self.showCancelButton(True) with open(fn, 'rb') as src, open(gn, 'wb') as dst: dst.write(bytes([0] * 16)) # spacer for MAC written at end dst.write(iv) dst.write(salt) # store iv, salt # is it safe to store MAC, iv, salt plain right in file? # can't really store them encrypted, # or elsewhere in this model of single file encryption? # can't have another file for the file to lug around # store file size, file name length dst.write(vault.encrypt(struct.pack('<2Q', fsz, fnz))) dst.write(vault.encrypt(fne)) # store filename while 1: dat = src.read(blksize) if not dat: break elif len(dat) % chk: # add padding fil = chk - len(dat) % chk dat += bytes(sample(chars, fil)) dst.write(vault.encrypt(dat)) csz += blksize # show progress self.encryptPbar.setValue(int(round(csz * 100.0 / fsz))) self.setWindowTitle(prog.format(csz / fsz, title)) app.processEvents() if self._requestStop: break if not self._requestStop: stuf = random.randrange(23) # pack in more stuffing fing = b''.join(bytes(sample(chars, 16)) for i in range(stuf)) dst.write(vault.encrypt(fing)) # and for annoyance dst.seek(0) dst.write(vault.digest()) # write MAC self.hashLabel.setText('MAC: ' + vault.hexdigest()) self.encryptPbar.setValue(self.encryptPbar.maximum()) self.setWindowTitle(title) self.showCancelButton(False) if self._requestStop: self.setMessage('Encryption canceled!') self.encryptPbar.setValue(self.encryptPbar.minimum()) self._requestStop = False os.remove(gn) return return gn def decrypt(self): "encrypt selected file with key" name, t0 = self.getName(), time.perf_counter() if not name: self.setMessage('No file selected') return if not os.path.exists(os.path.join(self.path, name)): self.setMessage('File does not exist') return key = str(self.keyInput.text()) if len(key) < self.minKeyLen: self.setMessage(('Key must be at least ' '{} characters long').format(self.minKeyLen)) return self.lock() gn = self.decryptFile(key, os.path.join(self.path, name)) if not gn: self.lock(False) return self.decrypted.append(os.path.basename(gn)) self.lock(False) self.populateTable(self.path) # repopulate folder list bn, tt = os.path.basename(gn), time.perf_counter() - t0 self.setMessage('Decrypted, saved "{}"'.format(bn, 13)) self.extraLabel.setText('Decrypting took ' + self.secs_fmt(tt)) def decryptFile(self, key, fn): "decrypts a file using AES (MODE_GCM)" blksize = self.blksize gn = hashlib.md5(os.path.basename(fn).encode()).hexdigest() gn = os.path.join(self.path, gn) # temporary name if os.path.exists(gn): self.setMessage('file already exists') return self.decryptPbar.reset() csz = 0.0 # current processed value chk, fnsz = AES.block_size, os.path.getsize(fn) prog, title = '({:.02%}) {}', self.windowTitle() try: with open(fn, 'rb') as src, open(gn, 'wb') as dst: # extract iv, salt MAC = src.read(16) iv = src.read(AES.block_size * 2) salt = src.read(256) vault = AES.new(self.hashKey(key, salt), AES.MODE_GCM, iv) self.showCancelButton(True) # extract file size, file name length sizes = src.read(struct.calcsize('<2Q')) fsz, fnz = struct.unpack('<2Q', vault.decrypt(sizes)) # extract filename; round up fnz to nearest chk rnz = fnz if not fnz % chk else fnz + chk - fnz % chk rfn = vault.decrypt(src.read(rnz))[:fnz].decode() self.setMessage('Found "{}"'.format(rfn), 13, (255, 211, 127)) while 1: dat = src.read(blksize) if not dat: break dst.write(vault.decrypt(dat)) csz += blksize # show progress self.decryptPbar.setValue(int(round(csz * 100.0 / fnsz))) self.setWindowTitle(prog.format(1 - (csz / fnsz), title)) app.processEvents() if self._requestStop: break if not self._requestStop: dst.truncate(fsz) # remove padding if not self._requestStop: vault.verify(MAC) self.hashLabel.setText('') except (ValueError, KeyError) as err: os.remove(gn) self.setMessage('Invalid decryption!') self.setWindowTitle(title) self.showCancelButton(False) return except Exception as err: os.remove(gn) self.setMessage('Invalid key or file!') self.setWindowTitle(title) self.showCancelButton(False) return self.decryptPbar.setValue(self.decryptPbar.maximum()) self.setWindowTitle(title) self.showCancelButton(False) if self._requestStop: self.setMessage('Decryption canceled!') self.decryptPbar.setValue(self.decryptPbar.minimum()) self._requestStop = False os.remove(gn) return # restore original file name name, ext = os.path.splitext(rfn) count = 1 fn = os.path.join(self.path, name + ext) while os.path.exists(fn): fn = os.path.join(self.path, name + '_{}'.format(count) + ext) count += 1 os.rename(gn, fn) # restore original name return fn # saved name def copyKeyHash(self, action): "copies either the key or the hash to clipboard" act = str(action.text()).lower() if 'key' in act: txt = str(self.keyInput.text()) elif 'hash' in act: txt = str(self.hashLabel.text()) else: self.setMessage('Invalid copy selection') return if not txt: self.setMessage('Empty text; Nothing to copy') return if 'key' in act: self.setMessage('Key copied to clipboard') elif 'hash' in act: self.setMessage('Hash copied to clipboard') else: self.setMessage('Invalid copy selection') return self.clipboard.clear() self.clipboard.setText(txt) def secs_fmt(self, s): "6357 -> '1h 45m 57s'" Y, D, H, M = 31556952, 86400, 3600, 60 y = int(s // Y) s -= y * Y d = int(s // D) s -= d * D h = int(s // H) s -= h * H m = int(s // M) s -= m * M r = (str(int(s)) if int(s) == s else str(round(s, 3))) + 's' if m: r = str(m) + 'm ' + r if h: r = str(h) + 'h ' + r if d: r = str(d) + 'd ' + r if y: r = str(y) + 'y ' + r return r.strip() def closeEvent(self, event): self.clipboard.clear() def setup(self): "constructs the gui" Fixed = QSizePolicy() MinimumExpanding = QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) self.minKeyLen = 8 self.maxKeyLen = 4096 self.splitter = QSplitter(self) self.splitter.setOrientation(Qt.Horizontal) self.splitter.splitterMoved.connect(self.splitterChanged) # left column self.leftColumn = QWidget() self.vl01 = QVBoxLayout() # left column - first item (0; horizonal layout 0) self.hl00 = QHBoxLayout() self.hl00.setSpacing(5) self.openButton = QPushButton('&Open') self.openButton.setToolTip('Open folder') self.openButton.setMinimumSize(60, 20) self.openButton.setMaximumSize(60, 20) self.openButton.setSizePolicy(Fixed) self.openButton.clicked.connect(self.getFolder) #ico = self.style().standardIcon(QStyle.SP_DirIcon) #self.openButton.setIcon(ico) self.folderLabel = QLabel() self.folderLabel.setMinimumSize(135, 20) self.folderLabel.setMaximumSize(16777215, 20) self.folderLabel.setSizePolicy(MinimumExpanding) self.hl00.insertWidget(0, self.openButton) self.hl00.insertWidget(1, self.folderLabel) # left column - second item (1) self.folderTable = QTableWidget() self.folderTable.setMinimumSize(200, 32) self.folderTable.horizontalHeader().setVisible(False) self.folderTable.horizontalHeader().setStretchLastSection(True) self.folderTable.verticalHeader().setVisible(False) self.folderTable.verticalHeader().setDefaultSectionSize(15) self.folderTable.itemChanged.connect(self.editFileName) # left column - third item (2) self.extraLabel = QLabel() self.extraLabel.setMinimumSize(200, 20) self.extraLabel.setMaximumSize(16777215, 20) self.extraLabel.setSizePolicy(MinimumExpanding) self.extraLabel.setTextInteractionFlags(Qt.LinksAccessibleByMouse) # finalize left column self.vl01.insertLayout(0, self.hl00) self.vl01.insertWidget(1, self.folderTable) self.vl01.insertWidget(2, self.extraLabel) self.leftColumn.setLayout(self.vl01) # right column self.rightColumn = QWidget() self.vl02 = QVBoxLayout() # right column - first item (0) self.messageLabel = QLabel() self.messageLabel.setMinimumSize(290, 20) self.messageLabel.setMaximumSize(16777215, 20) self.messageLabel.setSizePolicy(MinimumExpanding) self.messageLabel.setAlignment(Qt.AlignCenter) # right column - second item (2; horizontal layout 1) self.hl01 = QHBoxLayout() self.hl01.setSpacing(5) self.encryptButton = QPushButton('&Encrypt') #\U0001F512 self.encryptButton.setToolTip('Encrypt selected file') self.encryptButton.setMinimumSize(60, 20) self.encryptButton.setMaximumSize(60, 20) self.encryptButton.setSizePolicy(Fixed) self.encryptButton.clicked.connect(self.encrypt) self.encryptPbar = QProgressBar() self.encryptPbar.setMinimumSize(225, 20) self.encryptPbar.setMaximumSize(16777215, 20) self.encryptPbar.setSizePolicy(MinimumExpanding) self.encryptPbar.setTextVisible(False) palette = self.encryptPbar.palette() # color of progress bar color = QColor(211, 70, 0) palette.setColor(QPalette.Highlight, color) self.encryptPbar.setPalette(palette) self.hl01.insertWidget(0, self.encryptButton) self.hl01.insertWidget(1, self.encryptPbar) # right column - third item (3; horizontal layout 2) self.hl02 = QHBoxLayout() self.hl02.setSpacing(5) self.cancelButton = QPushButton('C&ANCEL') self.cancelButton.setToolTip('Cancels current operation') self.cancelButton.setMinimumSize(70, 24) self.cancelButton.setMaximumSize(70, 24) self.cancelButton.setSizePolicy(Fixed) self.cancelButton.clicked.connect(self.setCancel) font = self.cancelButton.font() font.setBold(True) self.cancelButton.setFont(font) self.cancelButton.blockSignals(True) self.cancelButton.setEnabled(False) self.cancelButton.hide() self._requestStop = False self.keyInput = QLineEdit() self.keyInput.setMinimumSize(225, 20) self.keyInput.setMaximumSize(16777215, 20) self.keyInput.setSizePolicy(MinimumExpanding) self.keyInput.setPlaceholderText('key') self.keyInput.setMaxLength(self.maxKeyLen) self.keyInput.setAlignment(Qt.AlignCenter) self.keyInput.textEdited.connect(self.showKeyLen) self.genKeyButton = QPushButton('&Gen Key') #\U0001F511 self.genKeyButton.setToolTip('Generate a random key') self.genKeyButton.setMinimumSize(60, 20) self.genKeyButton.setMaximumSize(60, 20) self.genKeyButton.setSizePolicy(Fixed) self.genKeyButton.clicked.connect(self.genKey) self.keySizeSB = QSpinBox() self.keySizeSB.setToolTip('Length of key to generate') self.keySizeSB.setRange(32, 1024) self.keySizeSB.setMinimumSize(40, 20) self.keySizeSB.setMaximumSize(40, 20) self.keySizeSB.setSizePolicy(Fixed) self.keySizeSB.setAlignment(Qt.AlignCenter) self.keySizeSB.setButtonSymbols(QSpinBox.NoButtons) self.keySizeSB.setWrapping(True) self.hl02.insertWidget(0, self.cancelButton) self.hl02.insertWidget(1, self.keyInput) self.hl02.insertWidget(2, self.genKeyButton) self.hl02.insertWidget(3, self.keySizeSB) # right column - fourth item (4; horizontal layout 3) self.hl03 = QHBoxLayout() self.hl03.setSpacing(5) self.decryptButton = QPushButton('&Decrypt') #\U0001F513 self.decryptButton.setToolTip('Decrypt selected file') self.decryptButton.setMinimumSize(60, 20) self.decryptButton.setMaximumSize(60, 20) self.decryptButton.setSizePolicy(Fixed) self.decryptButton.clicked.connect(self.decrypt) self.decryptPbar = QProgressBar() self.decryptPbar.setMinimumSize(225, 20) self.decryptPbar.setMaximumSize(16777215, 20) self.decryptPbar.setSizePolicy(MinimumExpanding) self.decryptPbar.setTextVisible(False) self.decryptPbar.setInvertedAppearance(True) palette = self.decryptPbar.palette() # color of progress bar color = QColor(0, 170, 255) palette.setColor(QPalette.Highlight, color) self.decryptPbar.setPalette(palette) self.hl03.insertWidget(0, self.decryptButton) self.hl03.insertWidget(1, self.decryptPbar) # right column - fifth item (7; horizontal layout 4) self.hl04 = QHBoxLayout() self.hl04.setSpacing(5) self.showKeyCB = QCheckBox('&Show Key') self.showKeyCB.setToolTip('Show/Hide key value') self.showKeyCB.setMinimumSize(75, 20) self.showKeyCB.setMaximumSize(75, 20) self.showKeyCB.setSizePolicy(Fixed) self.showKeyCB.clicked.connect(self.showKey) self.showKeyCB.setChecked(True) self.hashPbar = QProgressBar() self.hashPbar.setMinimumSize(150, 20) self.hashPbar.setMaximumSize(16777215, 20) self.hashPbar.setSizePolicy(MinimumExpanding) self.hashPbar.setTextVisible(False) palette = self.hashPbar.palette() # color of progress bar color = QColor(31, 120, 73) palette.setColor(QPalette.Highlight, color) self.hashPbar.setPalette(palette) self.hashButton = QPushButton('&Hash') self.hashButton.setToolTip('Determine file hash') self.hashButton.setMinimumSize(60, 20) self.hashButton.setMaximumSize(60, 20) self.hashButton.setSizePolicy(Fixed) menu = QMenu(self.hashButton) ico = self.style().standardIcon(QStyle.SP_DialogYesButton) for alg in sorted( filter(lambda x: 'shake' not in x, hashlib.algorithms_guaranteed), key=lambda n: (len(n), sorted(hashlib.algorithms_guaranteed).index(n))): menu.addAction( ico, alg ) # drop shake algs as their .hexdigest requires an argument - the rest don't menu.addAction(ico, 'Party') for i in menu.actions(): i.setIconVisibleInMenu(False) self.hashButton.setMenu(menu) menu.triggered.connect(self.genHash) self.hl04.insertWidget(0, self.showKeyCB) self.hl04.insertWidget(1, self.hashPbar) self.hl04.insertWidget(2, self.hashButton) # right column - sixth item (8; horizontal layout 5) self.hl05 = QHBoxLayout() self.hl05.setSpacing(5) self.copyButton = QPushButton('&Copy') #\U0001F4CB self.copyButton.setToolTip('Copy key or hash to clipboard') self.copyButton.setMinimumSize(60, 20) self.copyButton.setMaximumSize(60, 20) self.copyButton.setSizePolicy(Fixed) menu2 = QMenu(self.copyButton) menu2.addAction('Copy Key') menu2.addAction('Copy Hash') self.copyButton.setMenu(menu2) menu2.triggered.connect(self.copyKeyHash) self.hashLabel = QLabel() self.hashLabel.setMinimumSize(225, 20) self.hashLabel.setMaximumSize(16777215, 20) self.hashLabel.setSizePolicy(MinimumExpanding) self.hashLabel.setTextFormat(Qt.PlainText) self.hashLabel.setAlignment(Qt.AlignCenter) self.hashLabel.setTextInteractionFlags(Qt.TextSelectableByMouse) self.hl05.insertWidget(0, self.copyButton) self.hl05.insertWidget(1, self.hashLabel) # finalize right column self.vl02.insertWidget(0, self.messageLabel) self.vl02.insertSpacerItem(1, QSpacerItem(0, 0)) self.vl02.insertLayout(2, self.hl01) self.vl02.insertLayout(3, self.hl02) self.vl02.insertLayout(4, self.hl03) self.vl02.insertSpacerItem(5, QSpacerItem(0, 0)) self.vl02.insertWidget(6, QFrame()) self.vl02.insertLayout(7, self.hl04) self.vl02.insertLayout(8, self.hl05) self.rightColumn.setLayout(self.vl02) # finalize main window self.splitter.insertWidget(0, self.leftColumn) self.splitter.insertWidget(1, self.rightColumn) layout = QHBoxLayout(self) layout.addWidget(self.splitter) self.setLayout(layout) self.setWindowTitle('Simple File Encryptor/Decryptor') self.resize(self.sizeHint())
class CloningWidget(ToolWidget): def __init__(self, image, parent=None): super(CloningWidget, self).__init__(parent) self.detector_combo = QComboBox() self.detector_combo.addItems( [self.tr('BRISK'), self.tr('ORB'), self.tr('AKAZE')]) self.detector_combo.setCurrentIndex(0) self.detector_combo.setToolTip( self.tr('Algorithm used for localization and description')) self.response_spin = QSpinBox() self.response_spin.setRange(0, 100) self.response_spin.setSuffix(self.tr('%')) self.response_spin.setValue(90) self.response_spin.setToolTip( self.tr('Maximum keypoint response to perform matching')) self.matching_spin = QSpinBox() self.matching_spin.setRange(1, 100) self.matching_spin.setSuffix(self.tr('%')) self.matching_spin.setValue(20) self.matching_spin.setToolTip( self.tr('Maximum metric difference to accept matching')) self.distance_spin = QSpinBox() self.distance_spin.setRange(1, 100) self.distance_spin.setSuffix(self.tr('%')) self.distance_spin.setValue(15) self.distance_spin.setToolTip( self.tr('Maximum distance between matches in the same cluster')) self.cluster_spin = QSpinBox() self.cluster_spin.setRange(1, 20) self.cluster_spin.setValue(5) self.cluster_spin.setToolTip( self.tr('Minimum number of keypoints to create a new cluster')) self.kpts_check = QCheckBox(self.tr('Show keypoints')) self.kpts_check.setToolTip(self.tr('Show keypoint coverage')) self.nolines_check = QCheckBox(self.tr('Hide lines')) self.nolines_check.setToolTip(self.tr('Disable match line drawing')) self.process_button = QToolButton() self.process_button.setText(self.tr('Process')) self.process_button.setToolTip(self.tr('Perform automatic detection')) modify_font(self.process_button, bold=True) self.status_label = QLabel() self.mask_label = QLabel() self.mask_button = QToolButton() self.mask_button.setText(self.tr('Load mask...')) self.mask_button.setToolTip( self.tr('Load an image to be used as mask')) self.onoff_button = QToolButton() self.onoff_button.setText(self.tr('OFF')) self.onoff_button.setCheckable(True) self.onoff_button.setToolTip(self.tr('Toggle keypoint detection mask')) self.image = image self.viewer = ImageViewer(self.image, self.image) self.gray = cv.cvtColor(self.image, cv.COLOR_BGR2GRAY) self.total = self.kpts = self.desc = self.matches = self.clusters = self.mask = None self.canceled = False self.detector_combo.currentIndexChanged.connect(self.update_detector) self.response_spin.valueChanged.connect(self.update_detector) self.matching_spin.valueChanged.connect(self.update_matching) self.distance_spin.valueChanged.connect(self.update_cluster) self.cluster_spin.valueChanged.connect(self.update_cluster) self.nolines_check.stateChanged.connect(self.process) self.kpts_check.stateChanged.connect(self.process) self.process_button.clicked.connect(self.process) self.mask_button.clicked.connect(self.load_mask) self.onoff_button.toggled.connect(self.toggle_mask) self.onoff_button.setEnabled(False) top_layout = QHBoxLayout() top_layout.addWidget(QLabel(self.tr('Detector:'))) top_layout.addWidget(self.detector_combo) top_layout.addWidget(QLabel(self.tr('Response:'))) top_layout.addWidget(self.response_spin) top_layout.addWidget(QLabel(self.tr('Matching:'))) top_layout.addWidget(self.matching_spin) top_layout.addWidget(QLabel(self.tr('Distance:'))) top_layout.addWidget(self.distance_spin) top_layout.addWidget(QLabel(self.tr('Cluster:'))) top_layout.addWidget(self.cluster_spin) top_layout.addWidget(self.nolines_check) top_layout.addWidget(self.kpts_check) top_layout.addStretch() bottom_layout = QHBoxLayout() bottom_layout.addWidget(self.process_button) bottom_layout.addWidget(self.status_label) bottom_layout.addStretch() bottom_layout.addWidget(self.mask_button) bottom_layout.addWidget(self.onoff_button) main_layout = QVBoxLayout() main_layout.addLayout(top_layout) main_layout.addLayout(bottom_layout) main_layout.addWidget(self.viewer) self.setLayout(main_layout) def toggle_mask(self, checked): self.onoff_button.setText('ON' if checked else 'OFF') if checked: self.viewer.update_processed( cv.merge([c * self.mask for c in cv.split(self.image)])) else: self.viewer.update_processed(self.image) self.update_detector() def load_mask(self): filename, basename, mask = load_image(self) if filename is None: return if self.image.shape[:-1] != mask.shape[:-1]: QMessageBox.critical( self, self.tr('Error'), self.tr('Both image and mask must have the same size!')) return _, self.mask = cv.threshold(cv.cvtColor(mask, cv.COLOR_BGR2GRAY), 0, 1, cv.THRESH_BINARY) self.onoff_button.setEnabled(True) self.onoff_button.setChecked(True) self.mask_button.setText('"{}"'.format(splitext(basename)[0])) self.mask_button.setToolTip(self.tr('Current detection mask image')) def update_detector(self): self.total = self.kpts = self.desc = self.matches = self.clusters = None self.status_label.setText('') self.process_button.setEnabled(True) def update_matching(self): self.matches = self.clusters = None self.process_button.setEnabled(True) def update_cluster(self): self.clusters = None self.process_button.setEnabled(True) def cancel(self): self.canceled = True self.status_label.setText(self.tr('Processing interrupted!')) modify_font(self.status_label, bold=False, italic=False) def process(self): start = time() self.canceled = False self.status_label.setText(self.tr('Processing, please wait...')) algorithm = self.detector_combo.currentIndex() response = 100 - self.response_spin.value() matching = self.matching_spin.value() / 100 * 255 distance = self.distance_spin.value() / 100 cluster = self.cluster_spin.value() modify_font(self.status_label, bold=False, italic=True) QCoreApplication.processEvents() if self.kpts is None: if algorithm == 0: detector = cv.BRISK_create() elif algorithm == 1: detector = cv.ORB_create() elif algorithm == 2: detector = cv.AKAZE_create() else: return mask = self.mask if self.onoff_button.isChecked() else None self.kpts, self.desc = detector.detectAndCompute(self.gray, mask) self.total = len(self.kpts) responses = np.array([k.response for k in self.kpts]) strongest = (cv.normalize(responses, None, 0, 100, cv.NORM_MINMAX) >= response).flatten() self.kpts = list(compress(self.kpts, strongest)) if len(self.kpts) > 30000: QMessageBox.warning( self, self.tr('Warning'), self. tr('Too many keypoints found ({}), please reduce response value' .format(self.total))) self.kpts = self.desc = None self.total = 0 self.status_label.setText('') return self.desc = self.desc[strongest] if self.matches is None: matcher = cv.BFMatcher_create(cv.NORM_HAMMING, True) self.matches = matcher.radiusMatch(self.desc, self.desc, matching) if self.matches is None: self.status_label.setText( self.tr('No keypoint match found with current settings')) modify_font(self.status_label, italic=False, bold=True) return self.matches = [ item for sublist in self.matches for item in sublist ] self.matches = [ m for m in self.matches if m.queryIdx != m.trainIdx ] if not self.matches: self.clusters = [] elif self.clusters is None: self.clusters = [] min_dist = distance * np.min(self.gray.shape) / 2 kpts_a = np.array([p.pt for p in self.kpts]) ds = np.linalg.norm([ kpts_a[m.queryIdx] - kpts_a[m.trainIdx] for m in self.matches ], axis=1) self.matches = [ m for i, m in enumerate(self.matches) if ds[i] > min_dist ] total = len(self.matches) progress = QProgressDialog(self.tr('Clustering matches...'), self.tr('Cancel'), 0, total, self) progress.canceled.connect(self.cancel) progress.setWindowModality(Qt.WindowModal) for i in range(total): match0 = self.matches[i] d0 = ds[i] query0 = match0.queryIdx train0 = match0.trainIdx group = [match0] for j in range(i + 1, total): match1 = self.matches[j] query1 = match1.queryIdx train1 = match1.trainIdx if query1 == train0 and train1 == query0: continue d1 = ds[j] if np.abs(d0 - d1) > min_dist: continue a0 = np.array(self.kpts[query0].pt) b0 = np.array(self.kpts[train0].pt) a1 = np.array(self.kpts[query1].pt) b1 = np.array(self.kpts[train1].pt) aa = np.linalg.norm(a0 - a1) bb = np.linalg.norm(b0 - b1) ab = np.linalg.norm(a0 - b1) ba = np.linalg.norm(b0 - a1) if not (0 < aa < min_dist and 0 < bb < min_dist or 0 < ab < min_dist and 0 < ba < min_dist): continue for g in group: if g.queryIdx == train1 and g.trainIdx == query1: break else: group.append(match1) if len(group) >= cluster: self.clusters.append(group) progress.setValue(i) if self.canceled: self.update_detector() return progress.close() output = np.copy(self.image) hsv = np.zeros((1, 1, 3)) nolines = self.nolines_check.isChecked() show_kpts = self.kpts_check.isChecked() if show_kpts: for kpt in self.kpts: cv.circle(output, (int(kpt.pt[0]), int(kpt.pt[1])), 2, (250, 227, 72)) angles = [] for c in self.clusters: for m in c: ka = self.kpts[m.queryIdx] pa = tuple(map(int, ka.pt)) sa = int(np.round(ka.size)) kb = self.kpts[m.trainIdx] pb = tuple(map(int, kb.pt)) sb = int(np.round(kb.size)) angle = np.arctan2(pb[1] - pa[1], pb[0] - pa[0]) if angle < 0: angle += np.pi angles.append(angle) hsv[0, 0, 0] = angle / np.pi * 180 hsv[0, 0, 1] = 255 hsv[0, 0, 2] = m.distance / matching * 255 rgb = cv.cvtColor(hsv.astype(np.uint8), cv.COLOR_HSV2BGR) rgb = tuple([int(x) for x in rgb[0, 0]]) cv.circle(output, pa, sa, rgb, 1, cv.LINE_AA) cv.circle(output, pb, sb, rgb, 1, cv.LINE_AA) if not nolines: cv.line(output, pa, pb, rgb, 1, cv.LINE_AA) regions = 0 if angles: angles = np.reshape(np.array(angles, dtype=np.float32), (len(angles), 1)) if np.std(angles) < 0.1: regions = 1 else: criteria = (cv.TERM_CRITERIA_EPS + cv.TERM_CRITERIA_MAX_ITER, 10, 1.0) attempts = 10 flags = cv.KMEANS_PP_CENTERS compact = [ cv.kmeans(angles, k, None, criteria, attempts, flags)[0] for k in range(1, 11) ] compact = cv.normalize(np.array(compact), None, 0, 1, cv.NORM_MINMAX) regions = np.argmax(compact < 0.005) + 1 self.viewer.update_processed(output) self.process_button.setEnabled(False) modify_font(self.status_label, italic=False, bold=True) self.status_label.setText( self. tr('Keypoints: {} --> Filtered: {} --> Matches: {} --> Clusters: {} --> Regions: {}' .format(self.total, len(self.kpts), len(self.matches), len(self.clusters), regions))) self.info_message.emit( self.tr('Copy-Move Forgery = {}'.format(elapsed_time(start))))
class CloningWidget(ToolWidget): def __init__(self, image, parent=None): super(CloningWidget, self).__init__(parent) self.detector_combo = QComboBox() self.detector_combo.addItems( [self.tr('BRISK'), self.tr('ORB'), self.tr('AKAZE')]) self.detector_combo.setCurrentIndex(0) self.detector_combo.setToolTip( self.tr('Algorithm used for localization and description')) self.response_spin = QSpinBox() self.response_spin.setRange(0, 100) self.response_spin.setSuffix(self.tr('%')) self.response_spin.setValue(90) self.response_spin.setToolTip( self.tr('Maximum keypoint response to perform matching')) self.matching_spin = QSpinBox() self.matching_spin.setRange(1, 100) self.matching_spin.setSuffix(self.tr('%')) self.matching_spin.setValue(20) self.matching_spin.setToolTip( self.tr('Maximum metric difference to accept matching')) self.distance_spin = QSpinBox() self.distance_spin.setRange(1, 100) self.distance_spin.setSuffix(self.tr('%')) self.distance_spin.setValue(15) self.distance_spin.setToolTip( self.tr('Maximum distance between matches in the same cluster')) self.cluster_spin = QSpinBox() self.cluster_spin.setRange(1, 20) self.cluster_spin.setValue(5) self.cluster_spin.setToolTip( self.tr('Minimum number of keypoints to create a new cluster')) self.nolines_check = QCheckBox(self.tr('Hide lines')) self.nolines_check.setToolTip(self.tr('Disable match line drawing')) self.process_button = QPushButton(self.tr('Process')) self.process_button.setToolTip(self.tr('Perform automatic detection')) self.status_label = QLabel( self.tr('[Press "Process" button to search for cloned regions]')) self.image = image self.viewer = ImageViewer(self.image, self.image) self.gray = cv.cvtColor(self.image, cv.COLOR_BGR2GRAY) self.keypoints = self.kpts = self.desc = self.matches = self.clusters = None self.canceled = False self.detector_combo.currentIndexChanged.connect(self.update_detector) self.response_spin.valueChanged.connect(self.update_detector) self.matching_spin.valueChanged.connect(self.update_matching) self.distance_spin.valueChanged.connect(self.update_cluster) self.cluster_spin.valueChanged.connect(self.update_cluster) self.nolines_check.stateChanged.connect(self.process) self.process_button.clicked.connect(self.process) top_layout = QHBoxLayout() top_layout.addWidget(QLabel(self.tr('Detector:'))) top_layout.addWidget(self.detector_combo) top_layout.addWidget(QLabel(self.tr('Response:'))) top_layout.addWidget(self.response_spin) top_layout.addWidget(QLabel(self.tr('Matching:'))) top_layout.addWidget(self.matching_spin) top_layout.addWidget(QLabel(self.tr('Distance:'))) top_layout.addWidget(self.distance_spin) top_layout.addWidget(QLabel(self.tr('Cluster:'))) top_layout.addWidget(self.cluster_spin) top_layout.addWidget(self.nolines_check) top_layout.addWidget(self.process_button) top_layout.addStretch() main_layout = QVBoxLayout() main_layout.addLayout(top_layout) main_layout.addWidget(self.status_label) main_layout.addWidget(self.viewer) self.setLayout(main_layout) def update_detector(self): self.keypoints = self.kpts = self.desc = self.matches = self.clusters = None self.process_button.setEnabled(True) def update_matching(self): self.matches = self.clusters = None self.process_button.setEnabled(True) def update_cluster(self): self.clusters = None self.process_button.setEnabled(True) def cancel(self): self.canceled = True self.keypoints = self.kpts = self.desc = self.matches = self.clusters = None self.status_label.setText(self.tr('Processing interrupted!')) modify_font(self.status_label, bold=False, italic=False) def process(self): start = time() self.status_label.setText(self.tr('Processing, please wait...')) algorithm = self.detector_combo.currentIndex() response = 100 - self.response_spin.value() matching = self.matching_spin.value() / 100 * 255 distance = self.distance_spin.value() / 100 cluster = self.cluster_spin.value() modify_font(self.status_label, bold=False, italic=True) QCoreApplication.processEvents() if self.kpts is None: if algorithm == 0: detector = cv.BRISK_create() elif algorithm == 1: detector = cv.ORB_create() elif algorithm == 2: detector = cv.AKAZE_create() else: return self.kpts, self.desc = detector.detectAndCompute(self.gray, None) self.keypoints = len(self.kpts) responses = np.array([k.response for k in self.kpts]) strongest = (cv.normalize(responses, None, 0, 100, cv.NORM_MINMAX) >= response).flatten() self.kpts = list(compress(self.kpts, strongest)) self.desc = self.desc[strongest] if self.matches is None: matcher = cv.BFMatcher_create(cv.NORM_HAMMING, True) self.matches = matcher.radiusMatch(self.desc, self.desc, matching) if self.matches is None: self.status_label.setText( self.tr('No keypoint match found with current settings')) modify_font(self.status_label, italic=False, bold=True) return self.matches = [ item for sublist in self.matches for item in sublist ] self.matches = [ m for m in self.matches if m.queryIdx != m.trainIdx ] if self.clusters is None: self.clusters = [] total = len(self.matches) min_dist = distance * np.min(self.gray.shape) / 2 progress = QProgressDialog(self.tr('Clustering matches...'), self.tr('Cancel'), 0, total, self) progress.canceled.connect(self.cancel) progress.setWindowModality(Qt.WindowModal) for i in range(total): match0 = self.matches[i] query0 = match0.queryIdx train0 = match0.trainIdx group = [match0] a0 = np.array(self.kpts[query0].pt) b0 = np.array(self.kpts[train0].pt) d0 = np.linalg.norm(a0 - b0) if d0 < min_dist: continue for j in range(i + 1, total): match1 = self.matches[j] query1 = match1.queryIdx train1 = match1.trainIdx if query1 == train0 and train1 == query0: continue a1 = np.array(self.kpts[query1].pt) b1 = np.array(self.kpts[train1].pt) d1 = np.linalg.norm(a1 - b1) if d1 < min_dist or np.abs(d0 - d1) > min_dist: continue aa = np.linalg.norm(a0 - a1) bb = np.linalg.norm(b0 - b1) ab = np.linalg.norm(a0 - b1) ba = np.linalg.norm(b0 - a1) smallest = np.partition(np.array([aa, bb, ab, ba]), 1)[:2] if np.all(np.logical_and(smallest > 0, smallest < min_dist)): for g in group: if g.queryIdx == train1 and g.trainIdx == query1: break else: group.append(match1) if len(group) >= cluster: self.clusters.append(group) progress.setValue(i) if self.canceled: self.canceled = False return progress.setValue(total) output = np.copy(self.image) hsv = np.zeros((1, 1, 3)) nolines = self.nolines_check.isChecked() angles = [] for c in self.clusters: for m in c: ka = self.kpts[m.queryIdx] pa = tuple(map(int, ka.pt)) sa = int(np.round(ka.size)) kb = self.kpts[m.trainIdx] pb = tuple(map(int, kb.pt)) sb = int(np.round(kb.size)) angle = np.arctan2(pb[1] - pa[1], pb[0] - pa[0]) if angle < 0: angle += np.pi angles.append(angle) hsv[0, 0, 0] = angle / np.pi * 180 hsv[0, 0, 1] = 255 hsv[0, 0, 2] = m.distance / matching * 255 rgb = cv.cvtColor(hsv.astype(np.uint8), cv.COLOR_HSV2BGR) rgb = tuple([int(x) for x in rgb[0, 0]]) cv.circle(output, pa, sa, rgb, 1, cv.LINE_AA) cv.circle(output, pb, sb, rgb, 1, cv.LINE_AA) if not nolines: cv.line(output, pa, pb, rgb, 1, cv.LINE_AA) regions = 0 if angles: angles = np.reshape(np.array(angles, dtype=np.float32), (len(angles), 1)) if np.std(angles) < 0.1: regions = 1 else: criteria = (cv.TERM_CRITERIA_EPS + cv.TERM_CRITERIA_MAX_ITER, 10, 1.0) attempts = 10 flags = cv.KMEANS_PP_CENTERS compact = [ cv.kmeans(angles, k, None, criteria, attempts, flags)[0] for k in range(1, 11) ] compact = cv.normalize(np.array(compact), None, 0, 1, cv.NORM_MINMAX) regions = np.argmax(compact < 0.005) + 1 self.viewer.update_processed(output) self.process_button.setEnabled(False) modify_font(self.status_label, italic=False, bold=True) self.status_label.setText( self. tr('Keypoints: {} --> Filtered: {} --> Matches: {} --> Clusters: {} --> Regions: {}' .format(self.keypoints, len(self.kpts), len(self.matches), len(self.clusters), regions))) self.info_message.emit( self.tr('Copy-Move Forgery = {}'.format(elapsed_time(start))))
class RenameOptionsView(QWidget): """View responsible for holding renaming options. Attributes: layout (QVBoxLayout): Main layout of view. frame_layout (QVBoxLayout: Layout of frame which holds options. frame (QFrame): Frame surrounding options. prefix_h_layout (QHBoxLayout): Layout holding prefix options. complete_rename_h_layout (QHBoxLayout): Layout holding complete rename options. search_and_replace_h_layout (QHBoxLayout): Layout holding search and replace options. renumber_h_layout (QHBoxLayout): Layout holding renumber options. remove_ext_h_layout (QHBoxLayout): Layout holding remove options. change_ext_h_layout (QHBoxLayout): Layout holding change extension options. create_backup_h_layout (QHBoxLayout): Layout holding backup options. preview_h_layout (QHBoxLayout): Layout holding preview options. start_lbl (QLabel): Label for renumbering start. padding_lbl (QLabel): Label for renumbering padding. add_prefix_cb (QCheckBox): Used to signify the user wants to add a prefix to the renaming. prefix (QLineEdit): prefix to add. complete_rename_cb (QCheckBox): Used to signify the user wants to completely rename the file. new_name (QLineEdit): New name used when renaming. search_and_replace_cb (QCheckBox): Used to signify the user wants to partially rename files. find (QLineEdit): When searching and replacing this is what the user wants to search for. replace (QLineEdit): When searching and replacing this is what the user wants to replace with. renumber_cb (QCheckBox): Used to signify the user wants to renumber while renaming. start_num (QSpinBox): Number to start with when renumbering files. padding (QComboBox): Padding to apply to renaming when renumbering files. dot_cb (QCheckBox): When checked a dot will be used to separate the renumber from the name. remove_ext_cb (QCheckBox): Used to signify the user wants to remove extensions when renaming. backup_files_cb (QCheckBox): Used to signify the user wants to backup old files before renaming. change_ext_cb (QCheckBox): Used to signify the user wants to change the extension while renaming. change_ext (QLineEdit): New extension to add to the renamed file. preview_cb (QCheckBox): Used to signify the user wants to preview the rename before renaming. """ def __init__(self): super(RenameOptionsView, self).__init__() self.layout = QVBoxLayout() self.frame_layout = QVBoxLayout() self.options_lbl = QLabel(prefs.OPTIONS) self.frame = QFrame() self.prefix_h_layout = QHBoxLayout() self.complete_rename_h_layout = QHBoxLayout() self.search_and_replace_h_layout = QHBoxLayout() self.renumber_h_layout = QHBoxLayout() self.remove_ext_h_layout = QHBoxLayout() self.change_ext_h_layout = QHBoxLayout() self.create_backup_h_layout = QHBoxLayout() self.preview_h_layout = QHBoxLayout() self.start_lbl = QLabel(prefs.START_NUM) self.padding_lbl = QLabel(prefs.PADDING) self.add_prefix_cb = QCheckBox(prefs.PREFIX) self.prefix = QLineEdit(prefs.PREFIX_DEFAULT) self.complete_rename_cb = QCheckBox(prefs.COMPLETE_RENAME) self.new_name = QLineEdit(prefs.COMPLETE_RENAME_DEFAULT) self.search_and_replace_cb = QCheckBox(prefs.SEARCH_AND_REPLACE) self.find = QLineEdit(prefs.SEARCH_AND_REPLACE_DEFAULT) self.replace = QLineEdit(prefs.REPLACE_WITH_DEFAULT) self.renumber_cb = QCheckBox(prefs.RENUMBER) self.start_num = QSpinBox() self.padding = QComboBox() self.dot_cb = QCheckBox(prefs.USE_DOT) self.remove_ext_cb = QCheckBox(prefs.REMOVE_EXT) self.backup_files_cb = QCheckBox(prefs.BACKUP) self.change_ext_cb = QCheckBox(prefs.CHANGE_EXT) self.change_ext = QLineEdit(prefs.CHANGE_EXT_DEFAULT) self.preview_cb = QCheckBox(prefs.PREVIEW) self._configure() def _configure(self) -> None: """Configure the RenameOptionsView.""" self.frame.setLayout(self.frame_layout) self.frame.setFrameStyle(QFrame.StyledPanel | QFrame.Sunken) self.layout.addWidget(self.options_lbl) self.layout.addWidget(self.frame) self.add_prefix_cb.setToolTip(prefs.PREFIX_TOOLTIP) self.prefix.setDisabled(True) self.prefix.setMaximumWidth(prefs.PREFIX_WIDTH) self.prefix.setMinimumWidth(prefs.PREFIX_WIDTH) self.complete_rename_cb.setToolTip(prefs.COMPLETE_RENAME_TOOLTIP) self.new_name.setDisabled(True) self.new_name.setMaximumWidth(prefs.NEW_NAME_WIDTH) self.new_name.setMinimumWidth(prefs.NEW_NAME_WIDTH) self.search_and_replace_cb.setToolTip(prefs.SEARCH_AND_REPLACE_TOOLTIP) self.find.setDisabled(True) self.find.setMinimumWidth(prefs.FIND_WIDTH) self.find.setMaximumWidth(prefs.FIND_WIDTH) self.replace.setDisabled(True) self.replace.setMaximumWidth(prefs.REPLACE_WIDTH) self.replace.setMinimumWidth(prefs.REPLACE_WIDTH) self.renumber_cb.setToolTip(prefs.RENUMBER_TOOLTIP) self.start_num.setToolTip(prefs.START_NUM_TOOLTIP) self.start_num.setDisabled(True) self.start_num.setValue(prefs.START_NUM_DEFAULT) self.start_num.setMinimumWidth(prefs.START_NUM_MIN_WIDTH) self.padding.setToolTip(prefs.PADDING_TOOLTIP) self.padding.setDisabled(True) self.padding.addItems([str(x) for x in range(10)]) self.padding.setCurrentIndex(4) self.padding.setMinimumWidth(prefs.PADDING_MIN_WIDTH) self.dot_cb.setToolTip(prefs.USE_DOT_TOOLTIP) self.dot_cb.setDisabled(True) self.dot_cb.setChecked(True) self.dot_cb.setMinimumWidth(prefs.DOT_WIDTH) self.dot_cb.setMaximumWidth(prefs.DOT_WIDTH) self.remove_ext_cb.setToolTip(prefs.REMOVE_EXT_TOOLTIP) self.change_ext.setToolTip(prefs.CHANGE_EXT_TOOLTIP) self.change_ext.setDisabled(True) self.backup_files_cb.setToolTip(prefs.BACKUP_TOOLTIP) self.backup_files_cb.setChecked(True) self.preview_cb.setToolTip(prefs.PREVIEW_TOOLTIP) self.preview_cb.setChecked(True) self.prefix_h_layout.addWidget(self.add_prefix_cb) self.prefix_h_layout.addWidget(self.prefix) self.frame_layout.addLayout(self.prefix_h_layout) self.complete_rename_h_layout.addWidget(self.complete_rename_cb) self.complete_rename_h_layout.addWidget(self.new_name) self.frame_layout.addLayout(self.complete_rename_h_layout) self.search_and_replace_h_layout.addWidget(self.search_and_replace_cb) self.search_and_replace_h_layout.addWidget(self.find) self.search_and_replace_h_layout.addWidget(self.replace) self.frame_layout.addLayout(self.search_and_replace_h_layout) self.renumber_h_layout.addWidget(self.renumber_cb) self.renumber_h_layout.addStretch(1) self.renumber_h_layout.addWidget(self.start_lbl) self.renumber_h_layout.addWidget(self.start_num) self.renumber_h_layout.addSpacerItem(QSpacerItem(*prefs.SPACER_SIZE)) self.renumber_h_layout.addWidget(self.padding_lbl) self.renumber_h_layout.addWidget(self.padding) self.renumber_h_layout.addSpacerItem(QSpacerItem(*prefs.SPACER_SIZE)) self.renumber_h_layout.addWidget(self.dot_cb) self.frame_layout.addLayout(self.renumber_h_layout) self.change_ext_h_layout.addWidget(self.change_ext_cb) self.change_ext_h_layout.addWidget(self.change_ext) self.frame_layout.addLayout(self.change_ext_h_layout) self.remove_ext_h_layout.addWidget(self.remove_ext_cb) self.frame_layout.addLayout(self.remove_ext_h_layout) self.create_backup_h_layout.addWidget(self.backup_files_cb) self.frame_layout.addLayout(self.create_backup_h_layout) self.preview_h_layout.addWidget(self.preview_cb) self.frame_layout.addLayout(self.preview_h_layout) self.frame_layout.addSpacerItem(QSpacerItem(*prefs.SPACER_SIZE)) self.setLayout(self.layout) def disable_change_ext(self) -> None: """Disable change extension.""" self.change_ext.setDisabled(True) def disable_dot(self) -> None: """Disable dot checkbox.""" self.dot_cb.setDisabled(True) def disable_find(self) -> None: """Disable find.""" self.find.setDisabled(True) def disable_new_name(self) -> None: """Disable new name.""" print("disable new name") self.new_name.setDisabled(True) def disable_padding(self) -> None: """Disable padding.""" self.padding.setDisabled(True) def disable_prefix(self) -> None: """Disable prefix.""" self.prefix.setDisabled(True) def disable_start_num(self) -> None: """Disable start num.""" self.start_num.setDisabled(True) def disable_replace(self) -> None: """Disable replace.""" self.replace.setDisabled(True) def enable_change_ext(self) -> None: """Disable change extension.""" self.change_ext.setDisabled(False) def enable_dot(self) -> None: """Enable dot checkbox.""" self.dot_cb.setEnabled(True) def enable_find(self) -> None: """Enable find.""" self.find.setEnabled(True) def enable_new_name(self) -> None: """Enable new name.""" print("enable new name.") self.new_name.setEnabled(True) def enable_padding(self) -> None: """Enable padding.""" self.padding.setEnabled(True) def enable_prefix(self) -> None: """Enable prefix.""" self.prefix.setEnabled(True) def enable_replace(self) -> None: """Enable replace.""" self.replace.setEnabled(True) def enable_start_num(self) -> None: """Enable start num.""" self.start_num.setEnabled(True) def get_add_prefix(self) -> bool: """Return if end user wants to add a prefix and it is not the default value.""" result = self.get_prefix_checked() if result and self.get_prefix() == prefs.PREFIX_DEFAULT: result = False return result def get_do_backup(self) -> bool: """Return if end user wants to backup files.""" return self.backup_files_cb.isChecked() def get_change_ext(self) -> bool: """Return if the change extension checkbox is checked.""" return self.change_ext_cb.isChecked() def get_do_complete_rename(self) -> bool: """Get if end user wants to completely rename.""" return self.complete_rename_cb.isChecked() def get_dot(self) -> str: """Return dot string based on end users configuration. Note: If the end user has not enable using dot separators an empty string will be returned. """ return "." if self.get_do_dot() else "" def get_do_dot(self) -> bool: """Return if the end user wants to use dot separators when renaming.""" return self.dot_cb.isChecked() def get_do_change_ext(self) -> bool: """Return if the end user wants to change the extension.""" result = self.change_ext_cb.isChecked() if self.get_new_ext() == prefs.CHANGE_EXT_DEFAULT: return False return result def get_do_padding(self) -> bool: """Return if the end user wants to add padding.""" return False if self.get_padding() == 0 else True def get_do_preview(self) -> bool: """Return if the end user wants to preview changes.""" return self.preview_cb.isChecked() def get_do_rename(self) -> bool: """Return if end user wants to rename the full item and it is not the default value.""" result = self.complete_rename_cb.isChecked() if result and self.get_new_name() == prefs.COMPLETE_RENAME_DEFAULT: result = False return result def get_do_renumber(self) -> bool: """Return if the end user wants to renumber.""" return self.renumber_cb.isChecked() def get_do_search(self) -> bool: """Return if end user wants to perform a search and replace AND it is not the default values respectfully. Note: If you only want to know if search and replace is checked use get_search_and_replace. """ result = self.search_and_replace_cb.isChecked() if result and (self.get_find() == prefs.SEARCH_AND_REPLACE_DEFAULT or self.get_replace() == prefs.REPLACE_WITH_DEFAULT): result = False return result def get_do_search_and_replace(self) -> bool: """Return if end user wants to perform a search and replace.""" return self.search_and_replace_cb.isChecked() def get_find(self) -> str: """Return find value.""" return str(self.find.text()) def get_new_ext(self) -> str: """Return new ext.""" return str(self.change_ext.text()) def get_new_name(self) -> str: """Return new_name value.""" return str(self.new_name.text()) def get_padding(self) -> int: """Return the current padding value.""" return int(self.padding.currentText()) def get_prefix_checked(self) -> bool: """Return if the prefix checkbox is checked.""" return self.add_prefix_cb.isChecked() def get_prefix(self) -> str: """Return the current prefix value end user has entered.""" return str(self.prefix.text()) def get_remove_ext(self) -> bool: """Return if end user has checked the remove extension checkbox.""" return self.remove_ext_cb.isChecked() def get_replace(self) -> str: """Return the current replace value end user has entered.""" return str(self.replace.text()) def get_start_num(self) -> int: """Return start number from view.""" return int(self.start_num.value()) def set_change_ext_style(self, style: str) -> None: """Set style of change extension. Args: style: Style sheet applied to change extension. """ self.change_ext.setStyleSheet(style) def set_disabled(self) -> None: """Disable View.""" self.setDisabled(True) def set_enable(self) -> None: """Enable View.""" self.setEnabled(True) def set_find(self, value: str) -> None: """Set the value of find. Args: value: Value applied to find """ self.find.setText(value) def set_find_style(self, style: str) -> None: """Set style of find. Args: style: Style sheet applied to find. """ self.find.setStyleSheet(style) def set_new_name(self, value: str) -> None: """Set the value of new name. Args: value: Value applied to new_name """ self.new_name.setText(value) def set_new_name_style(self, style: str) -> None: """Set style of new_name. Args: style: Style sheet applied to new_name. """ self.new_name.setStyleSheet(style) def set_prefix(self, value: str) -> None: """Set the value of prefix. Args: value: Value applied to prefix """ self.prefix.setText(value) def set_prefix_style(self, style: str) -> None: """Set style of prefix. Args: style: Style sheet applied to prefix. """ self.prefix.setStyleSheet(style) def set_remove_ext(self, state: bool) -> None: """Set the remove_ext checkbox as checked or unchecked. Args: state: Check state of remove_ext. """ self.remove_ext_cb.setCheckState(Qt.Checked if state else Qt.Unchecked) def set_replace(self, value: str) -> None: """Set the value of replace. Args: value: Value applied to replace """ self.replace.setText(value) def set_replace_style(self, style: str) -> None: """Set style of replace. Args: style: Style sheet applied to replace. """ self.replace.setStyleSheet(style)
class TabDisplays(QTabWidget): def __init__(self, parent=None): super(TabDisplays, self).__init__(parent) # Initialize logging logging_conf_file = os.path.join(os.path.dirname(__file__), 'cfg/aecgviewer_aecg_logging.conf') logging.config.fileConfig(logging_conf_file) self.logger = logging.getLogger(__name__) self.studyindex_info = aecg.tools.indexer.StudyInfo() self.validator = QWidget() self.studyinfo = QWidget() self.statistics = QWidget() self.waveforms = QWidget() self.waveforms.setAccessibleName("Waveforms") self.scatterplot = QWidget() self.histogram = QWidget() self.trends = QWidget() self.xmlviewer = QWidget() self.xml_display = QTextEdit(self.xmlviewer) self.options = QWidget() self.aecg_display_area = QScrollArea() self.cbECGLayout = QComboBox() self.aecg_display = EcgDisplayWidget(self.aecg_display_area) self.aecg_display_area.setWidget(self.aecg_display) self.addTab(self.validator, "Study information") self.addTab(self.waveforms, "Waveforms") self.addTab(self.xmlviewer, "XML") self.addTab(self.options, "Options") self.setup_validator() self.setup_waveforms() self.setup_xmlviewer() self.setup_options() size = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) size.setHeightForWidth(False) self.setSizePolicy(size) # Initialized a threpool with 2 threads 1 for the GUI, 1 for long # tasks, so GUI remains responsive self.threadpool = QThreadPool() self.threadpool.setMaxThreadCount(2) self.validator_worker = None self.indexing_timer = QElapsedTimer() def setup_validator(self): self.directory_indexer = None # aecg.indexing.DirectoryIndexer() self.validator_layout_container = QWidget() self.validator_layout = QFormLayout() self.validator_form_layout = QFormLayout( self.validator_layout_container) self.validator_grid_layout = QGridLayout() self.study_info_file = QLineEdit() self.study_info_file.setToolTip("Study index file") self.study_info_description = QLineEdit() self.study_info_description.setToolTip("Description") self.app_type = QLineEdit() self.app_type.setToolTip("Application type (e.g., NDA, IND, BLA, IDE)") self.app_num = QLineEdit() self.app_num.setToolTip("Six-digit application number") self.app_num.setValidator(QIntValidator(self.app_num)) self.study_id = QLineEdit() self.study_id.setToolTip("Study identifier") self.study_sponsor = QLineEdit() self.study_sponsor.setToolTip("Sponsor of the study") self.study_annotation_aecg_cb = QComboBox() self.study_annotation_aecg_cb.addItems( ["Rhythm", "Derived beat", "Holter-rhythm", "Holter-derived"]) self.study_annotation_aecg_cb.setToolTip( "Waveforms used to perform the ECG measurements (i.e., " "annotations)\n" "\tRhythm: annotations in a rhythm strip or discrete ECG " "extraction (e.g., 10-s strips)\n" "\tDerived beat: annotations in a representative beat derived " "from a rhythm strip\n" "\tHolter-rhythm: annotations in a the analysis window of a " "continuous recording\n" "\tHolter-derived: annotations in a representative beat derived " "from analysis window of a continuous recording\n") self.study_annotation_lead_cb = QComboBox() self.ui_leads = ["GLOBAL"] + aecg.STD_LEADS[0:12] +\ [aecg.KNOWN_NON_STD_LEADS[1]] + aecg.STD_LEADS[12:15] + ["Other"] self.study_annotation_lead_cb.addItems(self.ui_leads) self.study_annotation_lead_cb.setToolTip( "Primary analysis lead annotated per protocol. There could be " "annotations in other leads also, but only the primary lead should" " be selected here.\n" "Select global if all leads were used at the " "same time (e.g., superimposed on screen).\n" "Select other if the primary lead used is not in the list.") self.study_numsubjects = QLineEdit() self.study_numsubjects.setToolTip( "Number of subjects with ECGs in the study") self.study_numsubjects.setValidator( QIntValidator(self.study_numsubjects)) self.study_aecgpersubject = QLineEdit() self.study_aecgpersubject.setToolTip( "Number of scheduled ECGs (or analysis windows) per subject as " "specified in the study protocol.\n" "Enter average number of ECGs " "per subject if the protocol does not specify a fixed number of " "ECGs per subject.") self.study_aecgpersubject.setValidator( QIntValidator(self.study_aecgpersubject)) self.study_numaecg = QLineEdit() self.study_numaecg.setToolTip( "Total number of aECG XML files in the study") self.study_numaecg.setValidator(QIntValidator(self.study_numaecg)) self.study_annotation_numbeats = QLineEdit() self.study_annotation_numbeats.setToolTip( "Minimum number of beats annotated in each ECG or analysis window" ".\nEnter 1 if annotations were done in the derived beat.") self.study_annotation_numbeats.setValidator( QIntValidator(self.study_annotation_numbeats)) self.aecg_numsubjects = QLineEdit() self.aecg_numsubjects.setToolTip( "Number of subjects found across the provided aECG XML files") self.aecg_numsubjects.setReadOnly(True) self.aecg_aecgpersubject = QLineEdit() self.aecg_aecgpersubject.setToolTip( "Average number of ECGs per subject found across the provided " "aECG XML files") self.aecg_aecgpersubject.setReadOnly(True) self.aecg_numaecg = QLineEdit() self.aecg_numaecg.setToolTip( "Number of aECG XML files found in the study aECG directory") self.aecg_numaecg.setReadOnly(True) self.subjects_less_aecgs = QLineEdit() self.subjects_less_aecgs.setToolTip( "Percentage of subjects with less aECGs than specified per " "protocol") self.subjects_less_aecgs.setReadOnly(True) self.subjects_more_aecgs = QLineEdit() self.subjects_more_aecgs.setToolTip( "Percentage of subjects with more aECGs than specified per " "protocol") self.subjects_more_aecgs.setReadOnly(True) self.aecgs_no_annotations = QLineEdit() self.aecgs_no_annotations.setToolTip( "Percentage of aECGs with no annotations") self.aecgs_no_annotations.setReadOnly(True) self.aecgs_less_qt_in_primary_lead = QLineEdit() self.aecgs_less_qt_in_primary_lead.setToolTip( "Percentage of aECGs with less QT intervals in the primary lead " "than specified per protocol") self.aecgs_less_qt_in_primary_lead.setReadOnly(True) self.aecgs_less_qts = QLineEdit() self.aecgs_less_qts.setToolTip( "Percentage of aECGs with less QT intervals than specified per " "protocol") self.aecgs_less_qts.setReadOnly(True) self.aecgs_annotations_multiple_leads = QLineEdit() self.aecgs_annotations_multiple_leads.setToolTip( "Percentage of aECGs with QT annotations in multiple leads") self.aecgs_annotations_multiple_leads.setReadOnly(True) self.aecgs_annotations_no_primary_lead = QLineEdit() self.aecgs_annotations_no_primary_lead.setToolTip( "Percentage of aECGs with QT annotations not in the primary lead") self.aecgs_annotations_no_primary_lead.setReadOnly(True) self.aecgs_with_errors = QLineEdit() self.aecgs_with_errors.setToolTip("Number of aECG files with errors") self.aecgs_with_errors.setReadOnly(True) self.aecgs_potentially_digitized = QLineEdit() self.aecgs_potentially_digitized.setToolTip( "Number of aECG files potentially digitized (i.e., with more than " "5% of samples missing)") self.aecgs_potentially_digitized.setReadOnly(True) self.study_dir = QLineEdit() self.study_dir.setToolTip("Directory containing the aECG files") self.study_dir_button = QPushButton("...") self.study_dir_button.clicked.connect(self.select_study_dir) self.study_dir_button.setToolTip("Open select directory dialog") self.validator_form_layout.addRow("Application Type", self.app_type) self.validator_form_layout.addRow("Application Number", self.app_num) self.validator_form_layout.addRow("Study name/ID", self.study_id) self.validator_form_layout.addRow("Sponsor", self.study_sponsor) self.validator_form_layout.addRow("Study description", self.study_info_description) self.validator_form_layout.addRow("Annotations in", self.study_annotation_aecg_cb) self.validator_form_layout.addRow("Annotations primary lead", self.study_annotation_lead_cb) self.validator_grid_layout.addWidget(QLabel(""), 0, 0) self.validator_grid_layout.addWidget( QLabel("Per study protocol or report"), 0, 1) self.validator_grid_layout.addWidget(QLabel("Found in aECG files"), 0, 2) self.validator_grid_layout.addWidget(QLabel("Number of subjects"), 1, 0) self.validator_grid_layout.addWidget(self.study_numsubjects, 1, 1) self.validator_grid_layout.addWidget(self.aecg_numsubjects, 1, 2) self.validator_grid_layout.addWidget( QLabel("Number of aECG per subject"), 2, 0) self.validator_grid_layout.addWidget(self.study_aecgpersubject, 2, 1) self.validator_grid_layout.addWidget(self.aecg_aecgpersubject, 2, 2) self.validator_grid_layout.addWidget(QLabel("Total number of aECG"), 3, 0) self.validator_grid_layout.addWidget(self.study_numaecg, 3, 1) self.validator_grid_layout.addWidget(self.aecg_numaecg, 3, 2) self.validator_grid_layout.addWidget( QLabel("Number of beats per aECG"), 4, 0) self.validator_grid_layout.addWidget(self.study_annotation_numbeats, 4, 1) self.validator_grid_layout.addWidget( QLabel("Subjects with fewer ECGs"), 5, 1) self.validator_grid_layout.addWidget(self.subjects_less_aecgs, 5, 2) self.validator_grid_layout.addWidget(QLabel("Subjects with more ECGs"), 6, 1) self.validator_grid_layout.addWidget(self.subjects_more_aecgs, 6, 2) self.validator_grid_layout.addWidget( QLabel("aECGs without annotations"), 7, 1) self.validator_grid_layout.addWidget(self.aecgs_no_annotations, 7, 2) self.validator_grid_layout.addWidget( QLabel("aECGs without expected number of QTs in primary lead"), 8, 1) self.validator_grid_layout.addWidget( self.aecgs_less_qt_in_primary_lead, 8, 2) self.validator_grid_layout.addWidget( QLabel("aECGs without expected number of QTs"), 9, 1) self.validator_grid_layout.addWidget(self.aecgs_less_qts, 9, 2) self.validator_grid_layout.addWidget( QLabel("aECGs annotated in multiple leads"), 10, 1) self.validator_grid_layout.addWidget( self.aecgs_annotations_multiple_leads, 10, 2) self.validator_grid_layout.addWidget( QLabel("aECGs with annotations not in primary lead"), 11, 1) self.validator_grid_layout.addWidget( self.aecgs_annotations_no_primary_lead, 11, 2) self.validator_grid_layout.addWidget(QLabel("aECGs with errors"), 12, 1) self.validator_grid_layout.addWidget(self.aecgs_with_errors, 12, 2) self.validator_grid_layout.addWidget( QLabel("Potentially digitized aECGs"), 13, 1) self.validator_grid_layout.addWidget(self.aecgs_potentially_digitized, 13, 2) self.validator_form_layout.addRow(self.validator_grid_layout) tmp = QHBoxLayout() tmp.addWidget(self.study_dir) tmp.addWidget(self.study_dir_button) self.validator_form_layout.addRow("Study aECGs directory", tmp) self.validator_form_layout.addRow("Study index file", self.study_info_file) self.validator_layout.addWidget(self.validator_layout_container) self.validator_effective_dirs = QLabel("") self.validator_effective_dirs.setWordWrap(True) self.validator_layout.addWidget(self.validator_effective_dirs) self.val_button = QPushButton("Generate/update study index") self.val_button.clicked.connect(self.importstudy_dialog) self.validator_layout.addWidget(self.val_button) self.cancel_val_button = QPushButton("Cancel study index generation") self.cancel_val_button.clicked.connect(self.cancel_validator) self.cancel_val_button.setEnabled(False) self.validator_layout.addWidget(self.cancel_val_button) self.validator_pl = QLabel("") self.validator_layout.addWidget(self.validator_pl) self.validator_pb = QProgressBar() self.validator_layout.addWidget(self.validator_pb) self.validator.setLayout(self.validator_layout) self.stop_indexing = False self.lastindexing_starttime = None self.update_validator_effective_dirs() def effective_aecgs_dir(self, navwidget, silent=False): aecgs_effective_dir = self.study_dir.text() if navwidget.project_loaded != '': # Path specified in the GUI potential_aecgs_dirs = [self.study_dir.text()] # StudyDir path from current working directory potential_aecgs_dirs += [self.studyindex_info.StudyDir] # StudyDir path from directory where the index is located potential_aecgs_dirs += [ os.path.join(os.path.dirname(navwidget.project_loaded), self.studyindex_info.StudyDir) ] # StudyDir replaced with the directory where the index is located potential_aecgs_dirs += [os.path.dirname(navwidget.project_loaded)] dir_found = False # Get xml and zip filenames from first element in the index aecg_xml_file = navwidget.data_index["AECGXML"][0] zipfile = "" if aecg_xml_file != "": zipfile = navwidget.data_index["ZIPFILE"][0] for p in potential_aecgs_dirs: testfn = os.path.join(p, aecg_xml_file) if zipfile != "": testfn = os.path.join(p, zipfile) if os.path.isfile(testfn): dir_found = True aecgs_effective_dir = p break if not silent: if not dir_found: QMessageBox.warning( self, f"Study aECGs directory not found", f"The following paths were checked:" f"{[','.join(p) for p in potential_aecgs_dirs]} and " f"none of them is valid") elif p != self.study_dir.text(): QMessageBox.warning( self, f"Study aECGs directory not found", f"The path specified in the study aECGs directory is " f"not valid and {p} is being used instead. Check and " f"update the path in Study aECGs directory textbox if " f"the suggested path is not the adequate path") return aecgs_effective_dir def update_validator_effective_dirs(self): msg = f"Working directory: {os.getcwd()}" if self.parent() is not None: if isinstance(self.parent(), QSplitter): navwidget = self.parent().parent() else: # Tabs widget has not been allocated the QSplitter yet navwidget = self.parent() project_loaded = navwidget.project_loaded if project_loaded != '': msg = f"{msg}\nLoaded project index: "\ f"{navwidget.project_loaded}" effective_aecgs_path = self.effective_aecgs_dir(navwidget) msg = f"{msg}\nEffective study aECGs directory: "\ f"{effective_aecgs_path}" else: msg = f"{msg}\nLoaded project index: None" else: msg = f"{msg}\nLoaded project index: None" self.validator_effective_dirs.setText(msg) def load_study_info(self, fileName): self.study_info_file.setText(fileName) try: study_info = pd.read_excel(fileName, sheet_name="Info") self.studyindex_info = aecg.tools.indexer.StudyInfo() self.studyindex_info.__dict__.update( study_info.set_index("Property").transpose().reset_index( drop=True).to_dict('index')[0]) sponsor = "" description = "" if self.studyindex_info.Sponsor is not None and\ isinstance(self.studyindex_info.Sponsor, str): sponsor = self.studyindex_info.Sponsor if self.studyindex_info.Description is not None and\ isinstance(self.studyindex_info.Description, str): description = self.studyindex_info.Description self.study_sponsor.setText(sponsor) self.study_info_description.setText(description) self.app_type.setText(self.studyindex_info.AppType) self.app_num.setText(f"{int(self.studyindex_info.AppNum):06d}") self.study_id.setText(self.studyindex_info.StudyID) self.study_numsubjects.setText(str(self.studyindex_info.NumSubj)) self.study_aecgpersubject.setText( str(self.studyindex_info.NECGSubj)) self.study_numaecg.setText(str(self.studyindex_info.TotalECGs)) anns_in = self.studyindex_info.AnMethod.upper() idx = 0 if anns_in == "RHYTHM": idx = 0 elif anns_in == "DERIVED": idx = 1 elif anns_in == "HOLTER_RHYTHM": idx = 2 elif anns_in == "HOLTER_MEDIAN_BEAT": idx = 3 else: idx = int(anns_in) - 1 self.study_annotation_aecg_cb.setCurrentIndex(idx) the_lead = self.studyindex_info.AnLead idx = self.study_annotation_lead_cb.findText(str(the_lead)) if idx == -1: idx = self.study_annotation_lead_cb.findText("MDC_ECG_LEAD_" + str(the_lead)) if idx == -1: idx = int(the_lead) self.study_annotation_lead_cb.setCurrentIndex(idx) self.study_annotation_numbeats.setText( str(self.studyindex_info.AnNbeats)) if self.studyindex_info.StudyDir == "": self.studyindex_info.StudyDir = os.path.dirname(fileName) self.study_dir.setText(self.studyindex_info.StudyDir) self.update_validator_effective_dirs() self.setCurrentWidget(self.validator) except Exception as ex: QMessageBox.critical( self, "Import study error", "Error reading the study information file: '" + fileName + "'") def setup_waveforms(self): wflayout = QVBoxLayout() # ECG plot layout selection box self.cbECGLayout.addItems( ['12-lead stacked', '3x4 + lead II rhythm', 'Superimposed']) self.cbECGLayout.currentIndexChanged.connect( self.ecgplotlayout_changed) # Zoom buttons blayout = QHBoxLayout() pb_zoomin = QPushButton() pb_zoomin.setText("Zoom +") pb_zoomin.clicked.connect(self.zoom_in) pb_zoomreset = QPushButton() pb_zoomreset.setText("Zoom 1:1") pb_zoomreset.clicked.connect(self.zoom_reset) pb_zoomout = QPushButton() pb_zoomout.setText("Zoom -") pb_zoomout.clicked.connect(self.zoom_out) blayout.addWidget(self.cbECGLayout) blayout.addWidget(pb_zoomout) blayout.addWidget(pb_zoomreset) blayout.addWidget(pb_zoomin) wflayout.addLayout(blayout) # Add QScrollArea to main layout of waveforms tab self.aecg_display_area.setWidgetResizable(False) wflayout.addWidget(self.aecg_display_area) self.waveforms.setLayout(wflayout) size = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) size.setHeightForWidth(False) self.aecg_display_area.setSizePolicy(size) self.waveforms.setSizePolicy(size) def setup_xmlviewer(self): wf_layout = QHBoxLayout() wf_layout.addWidget(self.xml_display) self.xmlviewer.setLayout(wf_layout) def setup_options(self): self.options_layout = QFormLayout() self.aecg_schema_filename = QLineEdit(aecg.get_aecg_schema_location()) self.options_layout.addRow("aECG XML schema path", self.aecg_schema_filename) self.save_index_every_n_aecgs = QSpinBox() self.save_index_every_n_aecgs.setMinimum(0) self.save_index_every_n_aecgs.setMaximum(50000) self.save_index_every_n_aecgs.setValue(0) self.save_index_every_n_aecgs.setSingleStep(100) self.save_index_every_n_aecgs.setSuffix(" aECGs") self.save_index_every_n_aecgs.setToolTip( "Set o 0 to save the study index file only after its generation " "is completed.\nOtherwise, the file is saved everytime the " " specified number of ECGs have been appended to the index.") self.options_layout.addRow("Save index every ", self.save_index_every_n_aecgs) self.save_all_intervals_cb = QCheckBox("") self.save_all_intervals_cb.setChecked(False) self.options_layout.addRow("Save individual beat intervals", self.save_all_intervals_cb) self.parallel_processing_cb = QCheckBox("") self.parallel_processing_cb.setChecked(True) self.options_layout.addRow("Parallel processing of files", self.parallel_processing_cb) self.options.setLayout(self.options_layout) def zoom_in(self): self.aecg_display.apply_zoom(self.aecg_display.zoom_factor + 0.1) def zoom_out(self): self.aecg_display.apply_zoom(self.aecg_display.zoom_factor - 0.1) def zoom_reset(self): self.aecg_display.apply_zoom(1.0) def ecgplotlayout_changed(self, i): self.aecg_display.update_aecg_plot( ecg_layout=aecg.utils.ECG_plot_layout(i + 1)) def update_search_progress(self, i, n): self.validator_pl.setText( f"Searching aECGs in directory ({n} XML files found)") def update_progress(self, i, n): j = i m = n if i <= 1: j = 1 if self.validator_pb.value() > 0: j = self.validator_pb.value() + 1 m = self.validator_pb.maximum() running_time = self.indexing_timer.elapsed() * 1e-3 # in seconds time_per_item = running_time / j # reamining = seconds per item so far * total pending items to process remaining_time = time_per_item * (m - j) eta = datetime.datetime.now() +\ datetime.timedelta(seconds=round(remaining_time, 0)) self.validator_pl.setText( f"Validating aECG {j}/{m} | " f"Execution time: " f"{str(datetime.timedelta(0,seconds=round(running_time)))} | " f"{round(1/time_per_item,2)} aECGs per second | " f"ETA: {eta.isoformat(timespec='seconds')}") self.validator_pb.setValue(j) if self.save_index_every_n_aecgs.value() > 0 and\ len(self.directory_indexer.studyindex) % \ self.save_index_every_n_aecgs.value() == 0: self.save_validator_results( pd.concat(self.directory_indexer.studyindex, ignore_index=True)) def save_validator_results(self, res): if res.shape[0] > 0: self.studyindex_info = aecg.tools.indexer.StudyInfo() self.studyindex_info.StudyDir = self.study_dir.text() self.studyindex_info.IndexFile = self.study_info_file.text() self.studyindex_info.Sponsor = self.study_sponsor.text() self.studyindex_info.Description =\ self.study_info_description.text() self.studyindex_info.Date = self.lastindexing_starttime.isoformat() self.studyindex_info.End_date = datetime.datetime.now().isoformat() self.studyindex_info.Version = aecg.__version__ self.studyindex_info.AppType = self.app_type.text() self.studyindex_info.AppNum = f"{int(self.app_num.text()):06d}" self.studyindex_info.StudyID = self.study_id.text() self.studyindex_info.NumSubj = int(self.study_numsubjects.text()) self.studyindex_info.NECGSubj = int( self.study_aecgpersubject.text()) self.studyindex_info.TotalECGs = int(self.study_numaecg.text()) anmethod = aecg.tools.indexer.AnnotationMethod( self.study_annotation_aecg_cb.currentIndex()) self.studyindex_info.AnMethod = anmethod.name self.studyindex_info.AnLead =\ self.study_annotation_lead_cb.currentText() self.studyindex_info.AnNbeats = int( self.study_annotation_numbeats.text()) # Calculate stats study_stats = aecg.tools.indexer.StudyStats( self.studyindex_info, res) # Save to file aecg.tools.indexer.save_study_index(self.studyindex_info, res, study_stats) validator_data_ready = Signal() def save_validator_results_and_load_index(self, res): self.save_validator_results(res) self.validator_data_ready.emit() def indexer_validator_results(self, res): self.studyindex_df = pd.concat([self.studyindex_df, res], ignore_index=True) def subindex_thread_complete(self): return def index_directory_thread_complete(self): tmp = self.validator_pl.text().replace("ETA:", "Completed: ").replace( "Validating", "Validated") self.validator_pl.setText(tmp) self.val_button.setEnabled(True) self.cancel_val_button.setEnabled(False) self.validator_layout_container.setEnabled(True) def index_directory(self, progress_callback): self.lastindexing_starttime = datetime.datetime.now() self.indexing_timer.start() studyindex_df = [] n_cores = os.cpu_count() aecg_schema = None if self.aecg_schema_filename.text() != "": aecg_schema = self.aecg_schema_filename.text() if self.parallel_processing_cb.isChecked(): studyindex_df = self.directory_indexer.index_directory( self.save_all_intervals_cb.isChecked(), aecg_schema, n_cores, progress_callback) else: studyindex_df = self.directory_indexer.index_directory( self.save_all_intervals_cb.isChecked(), aecg_schema, 1, progress_callback) return studyindex_df def importstudy_dialog(self): dirName = os.path.normpath(self.study_dir.text()) if dirName != "": if os.path.exists(dirName): self.directory_indexer = aecg.indexing.DirectoryIndexer() self.directory_indexer.set_aecg_dir( dirName, self.update_search_progress) self.validator_pb.setMaximum(self.directory_indexer.num_files) self.validator_pb.reset() self.stop_indexing = False self.validator_layout_container.setEnabled(False) self.val_button.setEnabled(False) self.cancel_val_button.setEnabled(True) self.validator_worker = Worker(self.index_directory) self.validator_worker.signals.result.connect( self.save_validator_results_and_load_index) self.validator_worker.signals.finished.connect( self.index_directory_thread_complete) self.validator_worker.signals.progress.connect( self.update_progress) # Execute self.threadpool.start(self.validator_worker) else: QMessageBox.critical( self, "Directory not found", f"Specified study directory not found:\n{dirName}") else: QMessageBox.critical(self, "Import study error", "Study directory cannot be empty") def cancel_validator(self): self.cancel_val_button.setEnabled(False) self.stop_indexing = True self.directory_indexer.cancel_indexing = True self.threadpool.waitForDone(3000) self.val_button.setEnabled(True) def select_study_dir(self): cd = self.study_dir.text() if cd == "": cd = "." dir = QFileDialog.getExistingDirectory( self, "Open Directory", cd, QFileDialog.ShowDirsOnly | QFileDialog.DontResolveSymlinks) if dir != "": self.study_dir.setText(dir)
class PreferencesGeneralPage(QWidget): preferencesChanged = Signal() def __init__(self, parent=None): super().__init__(parent) # Title title = QLabel(self.tr("<strong style=\"font-size:large;\">{0}</strong>").format(self.title())) # # Content: Geometry & State self._chkRestoreApplicationGeometry = QCheckBox(self.tr("Save and restore the application geometry")) self._chkRestoreApplicationGeometry.stateChanged.connect(self._onPreferencesChanged) self._chkRestoreApplicationState = QCheckBox(self.tr("Save and restore the application state")) self._chkRestoreApplicationState.stateChanged.connect(self._onPreferencesChanged) geometryStateLayout = QVBoxLayout() geometryStateLayout.addWidget(self._chkRestoreApplicationGeometry) geometryStateLayout.addWidget(self._chkRestoreApplicationState) geometryStateGroup = QGroupBox(self.tr("Geometry && State")) geometryStateGroup.setLayout(geometryStateLayout) # # Content: Recently Opened Documents self._spbMaximumRecentDocuments = QSpinBox() self._spbMaximumRecentDocuments.setRange(0, 25) self._spbMaximumRecentDocuments.setToolTip(self.tr("Maximum number of recently opened documents")) self._spbMaximumRecentDocuments.valueChanged.connect(self._onPreferencesChanged) self._spbMaximumRecentDocuments.valueChanged[int].connect(self._onMaximumRecentDocumentsChanged) self._chkRestoreRecentDocuments = QCheckBox(self.tr("Save and restore documents")) self._chkRestoreRecentDocuments.stateChanged.connect(self._onPreferencesChanged) recentDocumentsFormLayout = QFormLayout() recentDocumentsFormLayout.addRow(self.tr("Number of documents"), self._spbMaximumRecentDocuments) recentDocumentsLayout = QVBoxLayout() recentDocumentsLayout.addLayout(recentDocumentsFormLayout) recentDocumentsLayout.addWidget(self._chkRestoreRecentDocuments) recentDocumentsGroup = QGroupBox(self.tr("Recently Opened Documents")) recentDocumentsGroup.setLayout(recentDocumentsLayout) # Main layout self._layout = QVBoxLayout(self) self._layout.addWidget(title) self._layout.addWidget(geometryStateGroup) self._layout.addWidget(recentDocumentsGroup) self._layout.addStretch(1) def setZeroMargins(self): self._layout.setContentsMargins(0, 0, 0, 0) def title(self): return self.tr("General") def _onPreferencesChanged(self): self.preferencesChanged.emit() def _onMaximumRecentDocumentsChanged(self, val): self._chkRestoreRecentDocuments.setEnabled(val > 0) def setRestoreApplicationGeometry(self, checked): self._chkRestoreApplicationGeometry.setChecked(checked) def restoreApplicationGeometry(self): return self._chkRestoreApplicationGeometry.isChecked() def setRestoreApplicationState(self, checked): self._chkRestoreApplicationState.setChecked(checked) def restoreApplicationState(self): return self._chkRestoreApplicationState.isChecked() def setMaximumRecentDocuments(self, val): self._spbMaximumRecentDocuments.setValue(val) def maximumRecentDocuments(self): return self._spbMaximumRecentDocuments.value() def setRestoreRecentDocuments(self, checked): self._chkRestoreRecentDocuments.setChecked(checked) def restoreRecentDocuments(self): return self._chkRestoreRecentDocuments.isChecked()
class GradientWidget(ToolWidget): def __init__(self, image, parent=None): super(GradientWidget, self).__init__(parent) self.intensity_spin = QSpinBox() self.intensity_spin.setRange(0, 100) self.intensity_spin.setValue(95) self.intensity_spin.setSuffix(self.tr(" %")) self.intensity_spin.setToolTip(self.tr("Tonality compression amount")) self.blue_combo = QComboBox() self.blue_combo.addItems([ self.tr("None"), self.tr("Flat"), self.tr("Abs"), self.tr("Norm") ]) self.blue_combo.setCurrentIndex(2) self.blue_combo.setToolTip(self.tr("Blue component inclusion mode")) self.invert_check = QCheckBox(self.tr("Invert")) self.invert_check.setToolTip(self.tr("Reverse lighting direction")) self.equalize_check = QCheckBox(self.tr("Equalize")) self.equalize_check.setToolTip(self.tr("Apply histogram equalization")) self.image = image self.viewer = ImageViewer(self.image, self.image) self.dx, self.dy = cv.spatialGradient( cv.cvtColor(self.image, cv.COLOR_BGR2GRAY)) self.process() self.intensity_spin.valueChanged.connect(self.process) self.blue_combo.currentIndexChanged.connect(self.process) self.invert_check.stateChanged.connect(self.process) self.equalize_check.stateChanged.connect(self.process) top_layout = QHBoxLayout() top_layout.addWidget(QLabel(self.tr("Intensity:"))) top_layout.addWidget(self.intensity_spin) top_layout.addWidget(QLabel(self.tr("Blue channel:"))) top_layout.addWidget(self.blue_combo) top_layout.addWidget(self.invert_check) top_layout.addWidget(self.equalize_check) top_layout.addStretch() main_layout = QVBoxLayout() main_layout.addLayout(top_layout) main_layout.addWidget(self.viewer) self.setLayout(main_layout) def process(self): start = time() intensity = int(self.intensity_spin.value() / 100 * 127) invert = self.invert_check.isChecked() equalize = self.equalize_check.isChecked() self.intensity_spin.setEnabled(not equalize) blue_mode = self.blue_combo.currentIndex() if invert: dx = (-self.dx).astype(np.float32) dy = (-self.dy).astype(np.float32) else: dx = (+self.dx).astype(np.float32) dy = (+self.dy).astype(np.float32) dx_abs = np.abs(dx) dy_abs = np.abs(dy) red = ((dx / np.max(dx_abs) * 127) + 127).astype(np.uint8) green = ((dy / np.max(dy_abs) * 127) + 127).astype(np.uint8) if blue_mode == 0: blue = np.zeros_like(red) elif blue_mode == 1: blue = np.full_like(red, 255) elif blue_mode == 2: blue = norm_mat(dx_abs + dy_abs) elif blue_mode == 3: blue = norm_mat(np.linalg.norm(cv.merge((red, green)), axis=2)) else: blue = None gradient = cv.merge([blue, green, red]) if equalize: gradient = equalize_img(gradient) elif intensity > 0: gradient = cv.LUT(gradient, create_lut(intensity, intensity)) self.viewer.update_processed(gradient) self.info_message.emit( self.tr(f"Luminance Gradient = {elapsed_time(start)}"))
class ElaWidget(ToolWidget): def __init__(self, image, parent=None): super(ElaWidget, self).__init__(parent) self.quality_spin = QSpinBox() self.quality_spin.setRange(1, 100) self.quality_spin.setSuffix(self.tr(" %")) self.quality_spin.setToolTip(self.tr("JPEG reference quality level")) self.scale_spin = QSpinBox() self.scale_spin.setRange(1, 100) self.scale_spin.setSuffix(" %") self.scale_spin.setToolTip(self.tr("Output multiplicative gain")) self.contrast_spin = QSpinBox() self.contrast_spin.setRange(0, 100) self.contrast_spin.setSuffix(" %") self.contrast_spin.setToolTip(self.tr("Output tonality compression")) self.linear_check = QCheckBox(self.tr("Linear")) self.linear_check.setToolTip(self.tr("Linearize absolute difference")) self.gray_check = QCheckBox(self.tr("Grayscale")) self.gray_check.setToolTip(self.tr("Desaturated output")) default_button = QPushButton(self.tr("Default")) default_button.setToolTip(self.tr("Revert to default parameters")) params_layout = QHBoxLayout() params_layout.addWidget(QLabel(self.tr("Quality:"))) params_layout.addWidget(self.quality_spin) params_layout.addWidget(QLabel(self.tr("Scale:"))) params_layout.addWidget(self.scale_spin) params_layout.addWidget(QLabel(self.tr("Contrast:"))) params_layout.addWidget(self.contrast_spin) params_layout.addWidget(self.linear_check) params_layout.addWidget(self.gray_check) params_layout.addWidget(default_button) params_layout.addStretch() self.image = image self.original = image.astype(np.float32) / 255 self.compressed = None self.viewer = ImageViewer(self.image, self.image) self.default() self.quality_spin.valueChanged.connect(self.preprocess) self.scale_spin.valueChanged.connect(self.process) self.contrast_spin.valueChanged.connect(self.process) self.linear_check.stateChanged.connect(self.process) self.gray_check.stateChanged.connect(self.process) default_button.clicked.connect(self.default) main_layout = QVBoxLayout() main_layout.addLayout(params_layout) main_layout.addWidget(self.viewer) self.setLayout(main_layout) def preprocess(self): quality = self.quality_spin.value() self.compressed = compress_jpg(self.image, quality) self.process() def process(self): start = time() scale = self.scale_spin.value() contrast = int(self.contrast_spin.value() / 100 * 128) linear = self.linear_check.isChecked() grayscale = self.gray_check.isChecked() if not linear: difference = cv.absdiff(self.original, self.compressed.astype(np.float32) / 255) ela = cv.convertScaleAbs(cv.sqrt(difference) * 255, None, scale / 20) else: ela = cv.convertScaleAbs(cv.subtract(self.compressed, self.image), None, scale) ela = cv.LUT(ela, create_lut(contrast, contrast)) if grayscale: ela = desaturate(ela) self.viewer.update_processed(ela) self.info_message.emit(self.tr(f"Error Level Analysis = {elapsed_time(start)}")) def default(self): self.blockSignals(True) self.linear_check.setChecked(False) self.gray_check.setChecked(False) self.quality_spin.setValue(75) self.scale_spin.setValue(50) self.contrast_spin.setValue(25) self.blockSignals(False) self.preprocess()
class PreferencesDocumentPresetsPage(QWidget): preferencesChanged = Signal() def __init__(self, parent=None): super().__init__(parent) # Title title = QLabel( self.tr("<strong style=\"font-size:large;\">{0}</strong>").format( self.title())) # # Content: Header Labels rdbDefaultHeaderLabelHorizontalLetters = QRadioButton( self.tr("Letters")) rdbDefaultHeaderLabelHorizontalLetters.setToolTip( self. tr("Capital letters as default horizontal header labels of new documents" )) rdbDefaultHeaderLabelHorizontalNumbers = QRadioButton( self.tr("Numbers")) rdbDefaultHeaderLabelHorizontalNumbers.setToolTip( self. tr("Decimal numbers as default horizontal header labels of new documents" )) self._grpDefaultHeaderLabelHorizontal = QButtonGroup(self) self._grpDefaultHeaderLabelHorizontal.addButton( rdbDefaultHeaderLabelHorizontalLetters, Preferences.HeaderLabel.Letter.value) self._grpDefaultHeaderLabelHorizontal.addButton( rdbDefaultHeaderLabelHorizontalNumbers, Preferences.HeaderLabel.Decimal.value) self._grpDefaultHeaderLabelHorizontal.buttonClicked.connect( self._onPreferencesChanged) defaultHeaderLabelHorizontalBox = QHBoxLayout() defaultHeaderLabelHorizontalBox.addWidget( rdbDefaultHeaderLabelHorizontalLetters) defaultHeaderLabelHorizontalBox.addWidget( rdbDefaultHeaderLabelHorizontalNumbers) rdbDefaultHeaderLabelVerticalLetters = QRadioButton(self.tr("Letters")) rdbDefaultHeaderLabelVerticalLetters.setToolTip( self. tr("Capital letters as default vertical header labels of new documents" )) rdbDefaultHeaderLabelVerticalNumbers = QRadioButton(self.tr("Numbers")) rdbDefaultHeaderLabelVerticalNumbers.setToolTip( self. tr("Decimal numbers as default vertical header labels of new documents" )) self._grpDefaultHeaderLabelVertical = QButtonGroup() self._grpDefaultHeaderLabelVertical.addButton( rdbDefaultHeaderLabelVerticalLetters, Preferences.HeaderLabel.Letter.value) self._grpDefaultHeaderLabelVertical.addButton( rdbDefaultHeaderLabelVerticalNumbers, Preferences.HeaderLabel.Decimal.value) self._grpDefaultHeaderLabelVertical.buttonClicked.connect( self._onPreferencesChanged) defaultHeaderLabelVerticalBox = QHBoxLayout() defaultHeaderLabelVerticalBox.addWidget( rdbDefaultHeaderLabelVerticalLetters) defaultHeaderLabelVerticalBox.addWidget( rdbDefaultHeaderLabelVerticalNumbers) defaultHeaderLabelLayout = QFormLayout() defaultHeaderLabelLayout.addRow( self.tr("Labels of the horizontal header"), defaultHeaderLabelHorizontalBox) defaultHeaderLabelLayout.addRow( self.tr("Labels of the vertical header"), defaultHeaderLabelVerticalBox) defaultHeaderLabelGroup = QGroupBox(self.tr("Header Labels")) defaultHeaderLabelGroup.setLayout(defaultHeaderLabelLayout) # # Content: Cell Counts self._spbDefaultCellCountColumn = QSpinBox() self._spbDefaultCellCountColumn.setRange(1, 1000) self._spbDefaultCellCountColumn.setToolTip( self.tr("Default number of columns of new documents")) self._spbDefaultCellCountColumn.valueChanged.connect( self._onPreferencesChanged) self._spbDefaultCellCountRow = QSpinBox() self._spbDefaultCellCountRow.setRange(1, 1000) self._spbDefaultCellCountRow.setToolTip( self.tr("Default number of rows of new documents")) self._spbDefaultCellCountRow.valueChanged.connect( self._onPreferencesChanged) defaultCellCountLayout = QFormLayout() defaultCellCountLayout.addRow(self.tr("Number of columns"), self._spbDefaultCellCountColumn) defaultCellCountLayout.addRow(self.tr("Number of rows"), self._spbDefaultCellCountRow) defaultCellCountGroup = QGroupBox(self.tr("Cell Counts")) defaultCellCountGroup.setLayout(defaultCellCountLayout) # Main layout self._layout = QVBoxLayout(self) self._layout.addWidget(title) self._layout.addWidget(defaultHeaderLabelGroup) self._layout.addWidget(defaultCellCountGroup) self._layout.addStretch(1) def setZeroMargins(self): self._layout.setContentsMargins(0, 0, 0, 0) def title(self): return self.tr("Document Presets") def _onPreferencesChanged(self): self.preferencesChanged.emit() def setDefaultHeaderLabelHorizontal(self, type): if type.value != self._grpDefaultHeaderLabelHorizontal.checkedId(): self._onPreferencesChanged() for button in self._grpDefaultHeaderLabelHorizontal.buttons(): if self._grpDefaultHeaderLabelHorizontal.id(button) == type.value: button.setChecked(True) def defaultHeaderLabelHorizontal(self): return Preferences.HeaderLabel( self._grpDefaultHeaderLabelHorizontal.checkedId()) def setDefaultHeaderLabelVertical(self, type): if type.value != self._grpDefaultHeaderLabelVertical.checkedId(): self._onPreferencesChanged() for button in self._grpDefaultHeaderLabelVertical.buttons(): if self._grpDefaultHeaderLabelVertical.id(button) == type.value: button.setChecked(True) def defaultHeaderLabelVertical(self): return Preferences.HeaderLabel( self._grpDefaultHeaderLabelVertical.checkedId()) def setDefaultCellCountColumn(self, val): self._spbDefaultCellCountColumn.setValue(val) def defaultCellCountColumn(self): return self._spbDefaultCellCountColumn.value() def setDefaultCellCountRow(self, val): self._spbDefaultCellCountRow.setValue(val) def defaultCellCountRow(self): return self._spbDefaultCellCountRow.value()
class PilotSettingsBox(QGroupBox): def __init__(self, game: Game) -> None: super().__init__("Pilots and Squadrons") self.game = game layout = QGridLayout() self.setLayout(layout) self.ai_pilot_levelling = QCheckBox() self.ai_pilot_levelling.setChecked(self.game.settings.ai_pilot_levelling) self.ai_pilot_levelling.toggled.connect(self.set_ai_pilot_leveling) ai_pilot_levelling_info = ( "Set whether or not AI pilots will level up after completing a number of" " sorties. Since pilot level affects the AI skill, you may wish to disable" " this, lest you face an Ace!" ) self.ai_pilot_levelling.setToolTip(ai_pilot_levelling_info) ai_pilot_levelling_label = QLabel("Allow AI pilot levelling") ai_pilot_levelling_label.setToolTip(ai_pilot_levelling_info) layout.addWidget(ai_pilot_levelling_label, 0, 0) layout.addWidget(self.ai_pilot_levelling, 0, 1, Qt.AlignRight) enable_squadron_pilot_limits_info = ( "If set, squadrons will be limited to a maximum number of pilots and dead " "pilots will replenish at a fixed rate, each defined with the settings" "below. Auto-purchase may buy aircraft for which there are no pilots" "available, so this feature is still a work-in-progress." ) enable_squadron_pilot_limits_label = QLabel( "Enable per-squadron pilot limtits (WIP)" ) enable_squadron_pilot_limits_label.setToolTip(enable_squadron_pilot_limits_info) enable_squadron_pilot_limits = QCheckBox() enable_squadron_pilot_limits.setToolTip(enable_squadron_pilot_limits_info) enable_squadron_pilot_limits.setChecked( self.game.settings.enable_squadron_pilot_limits ) enable_squadron_pilot_limits.toggled.connect( self.set_enable_squadron_pilot_limits ) layout.addWidget(enable_squadron_pilot_limits_label, 1, 0) layout.addWidget(enable_squadron_pilot_limits, 1, 1, Qt.AlignRight) self.pilot_limit = QSpinBox() self.pilot_limit.setMinimum(12) self.pilot_limit.setMaximum(72) self.pilot_limit.setValue(self.game.settings.squadron_pilot_limit) self.pilot_limit.setEnabled(self.game.settings.enable_squadron_pilot_limits) self.pilot_limit.valueChanged.connect(self.set_squadron_pilot_limit) pilot_limit_info = ( "Sets the maximum number of pilots a squadron may have active. " "Changing this value will not have an immediate effect, but will alter " "replenishment for future turns." ) self.pilot_limit.setToolTip(pilot_limit_info) pilot_limit_label = QLabel("Maximum number of pilots per squadron") pilot_limit_label.setToolTip(pilot_limit_info) layout.addWidget(pilot_limit_label, 2, 0) layout.addWidget(self.pilot_limit, 2, 1, Qt.AlignRight) self.squadron_replenishment_rate = QSpinBox() self.squadron_replenishment_rate.setMinimum(1) self.squadron_replenishment_rate.setMaximum(20) self.squadron_replenishment_rate.setValue( self.game.settings.squadron_replenishment_rate ) self.squadron_replenishment_rate.setEnabled( self.game.settings.enable_squadron_pilot_limits ) self.squadron_replenishment_rate.valueChanged.connect( self.set_squadron_replenishment_rate ) squadron_replenishment_rate_info = ( "Sets the maximum number of pilots that will be recruited to each squadron " "at the end of each turn. Squadrons will not recruit new pilots beyond the " "pilot limit, but each squadron with room for more pilots will recruit " "this many pilots each turn up to the limit." ) self.squadron_replenishment_rate.setToolTip(squadron_replenishment_rate_info) squadron_replenishment_rate_label = QLabel("Squadron pilot replenishment rate") squadron_replenishment_rate_label.setToolTip(squadron_replenishment_rate_info) layout.addWidget(squadron_replenishment_rate_label, 3, 0) layout.addWidget(self.squadron_replenishment_rate, 3, 1, Qt.AlignRight) def set_enable_squadron_pilot_limits(self, checked: bool) -> None: self.game.settings.enable_squadron_pilot_limits = checked self.pilot_limit.setEnabled(checked) self.squadron_replenishment_rate.setEnabled(checked) def set_squadron_pilot_limit(self, value: int) -> None: self.game.settings.squadron_pilot_limit = value def set_squadron_replenishment_rate(self, value: int) -> None: self.game.settings.squadron_replenishment_rate = value def set_ai_pilot_leveling(self, checked: bool) -> None: self.game.settings.ai_pilot_levelling = checked