diff --git a/PPOCRLabel.py b/PPOCRLabel.py index 04f63e5..1eeb2ab 100644 --- a/PPOCRLabel.py +++ b/PPOCRLabel.py @@ -22,7 +22,10 @@ import signal import subprocess import sys +import torch # noqa: F401 — keeps torch DLLs loaded for PaddlePaddle on Windows from functools import partial +from PyQt5.QtWidgets import QShortcut +from PyQt5.QtGui import QKeySequence import openpyxl import cv2 @@ -398,6 +401,7 @@ def get_str(str_id): labelListContainer.setLayout(listLayout) self.labelList.itemSelectionChanged.connect(self.labelSelectionChanged) self.labelList.clicked.connect(self.labelList.item_clicked) + self.labelList.navigated.connect(self.focusAndZoom) # Connect to itemChanged to detect checkbox changes. self.labelList.itemChanged.connect(self.labelItemChanged) @@ -482,6 +486,35 @@ def get_str(str_id): self.imageSliderDock.setAttribute(Qt.WA_TranslucentBackground) self.addDockWidget(Qt.RightDockWidgetArea, self.imageSliderDock) + # ================== Confidence Filter ================== + self.confidenceSlider = QSlider(Qt.Horizontal) + self.confidenceSlider.setMinimum(0) + self.confidenceSlider.setMaximum(100) + self.confidenceSlider.setValue(100) + self.confidenceSlider.setSingleStep(1) + self.confidenceSlider.setToolTip( + "Show only boxes with confidence BELOW this threshold" + ) + self.confidenceSlider.valueChanged.connect(self.onConfidenceFilterChanged) + + self.confidenceLabel = QLabel("Threshold: 1.00 (show all)") + self.confidenceLabel.setAlignment(Qt.AlignCenter) + + confWidget = QWidget() + confLayout = QVBoxLayout() + confLayout.setContentsMargins(4, 4, 4, 4) + confLayout.addWidget(self.confidenceLabel) + confLayout.addWidget(self.confidenceSlider) + confWidget.setLayout(confLayout) + + self.confidenceFilterDock = QDockWidget("Confidence Filter", self) + self.confidenceFilterDock.setObjectName("ConfidenceFilter") + self.confidenceFilterDock.setWidget(confWidget) + self.confidenceFilterDock.setFeatures( + QDockWidget.DockWidgetFloatable | QDockWidget.DockWidgetClosable + ) + self.addDockWidget(Qt.RightDockWidgetArea, self.confidenceFilterDock) + self.zoomWidget = ZoomWidget() self.colorDialog = ColorDialog(parent=self) self.zoomWidgetValue = self.zoomWidget.value() @@ -1179,6 +1212,14 @@ def get_str(str_id): self.displayIndexOption.setChecked(settings.get(SETTING_PAINT_INDEX, False)) self.autoSaveUnsavedChangesOption.triggered.connect(self.autoSaveFunc) + QShortcut( + QKeySequence(Qt.Key_F2), + self, + activated=lambda: ( + self.labelList.activate_edit() if self.currentItem() else None + ), + ) + addActions( self.menus.file, ( @@ -1331,6 +1372,40 @@ def keyPressEvent(self, event): if event.key() == Qt.Key_Control: # Draw rectangle if Ctrl is pressed self.canvas.setDrawingShapeToSquare(True) + elif event.key() in (Qt.Key_F2, Qt.Key_Return, Qt.Key_Enter): + if self.currentItem() is not None: + self.labelList.activate_edit() + event.accept() + + def _navigateLabel(self, direction): + count = self.labelList.count() + if count == 0: + return + current = self.labelList.currentRow() + if current < 0: + next_row = 0 if direction > 0 else count - 1 + else: + next_row = (current + direction) % count + self.labelList.setCurrentRow(next_row) + self.labelList.scrollToItem(self.labelList.item(next_row)) + self.focusAndZoom() + + def onConfidenceFilterChanged(self, value): + threshold = value / 100.0 + if value == 100: + self.confidenceLabel.setText("Threshold: 1.00 (show all)") + else: + self.confidenceLabel.setText(f"Threshold: {threshold:.2f}") + self.applyConfidenceFilter() + + def applyConfidenceFilter(self): + threshold = self.confidenceSlider.value() / 100.0 + for shape in self.canvas.shapes: + if shape.score is None: + self.canvas.setShapeVisible(shape, True) + else: + self.canvas.setShapeVisible(shape, shape.score < threshold) + self.canvas.update() def noShapes(self): return not self.itemsToShapes @@ -1896,7 +1971,9 @@ def remLabels(self, shapes): def loadLabels(self, shapes): s = [] shape_index = 0 - for label, points, line_color, key_cls in shapes: + for item in shapes: + label, points, line_color, key_cls = item[:4] + score = item[4] if len(item) > 4 else None shape = Shape( label=label, line_color=line_color, @@ -1912,7 +1989,7 @@ def loadLabels(self, shapes): shape.addPoint(QPointF(x, y)) shape.idx = shape_index shape_index += 1 - # shape.locked = False + shape.score = score shape.close() s.append(shape) @@ -1961,13 +2038,16 @@ def saveLabels(self, annotationFilePath, mode="Auto"): def format_shape(s): # print('s in saveLabels is ',s) - return dict( + d = dict( label=s.label, # str line_color=s.line_color.getRgb(), fill_color=s.fill_color.getRgb(), points=[(int(p.x()), int(p.y())) for p in s.points], # QPonitF key_cls=s.key_cls, - ) # bool + ) + if s.score is not None: + d["score"] = round(float(s.score), 4) + return d if mode == "Auto": shapes = [] @@ -1979,7 +2059,7 @@ def format_shape(s): ] # Can add different annotation formats here for box in self.result_dic: - trans_dic = {"label": box[1][0], "points": box[0]} + trans_dic = {"label": box[1][0], "points": box[0], "score": box[1][1]} if self.kie_mode: if len(box) == 3: trans_dic.update({"key_cls": box[2]}) @@ -1997,6 +2077,8 @@ def format_shape(s): "points": box["points"], "difficult": False, } + if box.get("score") is not None: + trans_dict["score"] = round(float(box["score"]), 4) if self.kie_mode: trans_dict.update({"key_cls": box["key_cls"]}) trans_dic.append(trans_dict) @@ -2032,6 +2114,11 @@ def labelSelectionChanged(self): selected_shapes.append(self.itemsToShapes[item]) if selected_shapes: self.canvas.selectShapes(selected_shapes) + score = selected_shapes[0].score + if score is not None: + self.confidenceLabel.setText( + f"Threshold: {self.confidenceSlider.value() / 100:.2f} | Selected: {score:.2f}" + ) else: self.canvas.deSelectShape() @@ -2420,6 +2507,7 @@ def showBoundingBoxFromPPlabel(self, filePath): [[s[0] * width, s[1] * height] for s in box["ratio"]], DEFAULT_LOCK_COLOR, key_cls, + None, ) ) else: @@ -2429,6 +2517,7 @@ def showBoundingBoxFromPPlabel(self, filePath): [[s[0] * width, s[1] * height] for s in box["ratio"]], DEFAULT_LOCK_COLOR, key_cls, + None, ) ) if img_idx in self.PPlabel.keys(): @@ -2440,12 +2529,14 @@ def showBoundingBoxFromPPlabel(self, filePath): box["points"], None, key_cls, + box.get("score"), ) ) if shapes: self.loadLabels(shapes) self.canvas.verified = False + self.applyConfidenceFilter() def validFilestate(self, filePath): if filePath in self.fileStatedict.keys() and self.fileStatedict[filePath] == 1: @@ -2588,7 +2679,9 @@ def openDatasetDirDialog(self): else: if self.lang == "ch": - self.msgBox.warning(self, "提示", "\n 原文件夹已不存在,请从新选择数据集路径!") + self.msgBox.warning( + self, "提示", "\n 原文件夹已不存在,请从新选择数据集路径!" + ) else: self.msgBox.warning( self, @@ -3248,6 +3341,9 @@ def autoRecognitionCurrent(self): self.loadFile(self.filePath, isAdjustScale=False) self.canvas.isInTheSameImage = False self.setDirty() + for shape, box in zip(self.canvas.shapes, self.result_dic): + shape.score = box[1][1] + self.applyConfidenceFilter() def singleRerecognition(self): img = cv2.imdecode(np.fromfile(self.filePath, dtype=np.uint8), cv2.IMREAD_COLOR) diff --git a/libs/editinlist.py b/libs/editinlist.py index 811fe39..f14be14 100644 --- a/libs/editinlist.py +++ b/libs/editinlist.py @@ -1,13 +1,16 @@ # !/usr/bin/env python # -*- coding: utf-8 -*- -from PyQt5.QtCore import QModelIndex -from PyQt5.QtWidgets import QListWidget +from PyQt5.QtCore import QEvent, QModelIndex, pyqtSignal +from PyQt5.QtWidgets import QApplication, QLineEdit, QListWidget class EditInList(QListWidget): + navigated = pyqtSignal() # emitted after Tab/Shift+Tab navigation + def __init__(self): super(EditInList, self).__init__() self.edited_item = None + self._app_filter_active = False def item_clicked(self, modelindex: QModelIndex): try: @@ -19,6 +22,7 @@ def item_clicked(self, modelindex: QModelIndex): self.edited_item = self.item(modelindex.row()) self.openPersistentEditor(self.edited_item) self.editItem(self.edited_item) + self._install_app_filter() def mouseDoubleClickEvent(self, event): pass @@ -26,8 +30,99 @@ def mouseDoubleClickEvent(self, event): def leaveEvent(self, event): pass + def activate_edit(self): + """Open persistent editor for the currently selected item (called by F2/Tab).""" + item = self.currentItem() + if item is None: + return + if self.edited_item is not None: + try: + self.closePersistentEditor(self.edited_item) + except Exception: + pass + self.edited_item = item + self.openPersistentEditor(item) + self.editItem(item) + self._install_app_filter() + + def _install_app_filter(self): + if not self._app_filter_active: + QApplication.instance().installEventFilter(self) + self._app_filter_active = True + + def _remove_app_filter(self): + if self._app_filter_active: + QApplication.instance().removeEventFilter(self) + self._app_filter_active = False + + def _save_and_close(self): + """Write editor text back to the item, then close all editors.""" + if self.edited_item is not None: + # Use the currently focused widget — guaranteed to be what user typed in + focused = QApplication.focusWidget() + if isinstance(focused, QLineEdit): + self.edited_item.setText(focused.text()) + else: + # Fallback: search visible QLineEdit children + for child in self.findChildren(QLineEdit): + if child.isVisible(): + self.edited_item.setText(child.text()) + break + self._remove_app_filter() + for i in range(self.count()): + self.closePersistentEditor(self.item(i)) + self.edited_item = None + + def eventFilter(self, obj, event): + if event.type() == QEvent.KeyPress: + focused = QApplication.focusWidget() + # Only act when the focused widget is inside this list + if focused is not None and self.isAncestorOf(focused): + key = event.key() + if key == 16777217: # Tab: save, move to next, open editor + self._save_and_close() + next_row = self.currentRow() + 1 + if next_row < self.count(): + self.setCurrentRow(next_row) + self.scrollToItem(self.item(next_row)) + self.activate_edit() + self.navigated.emit() + return True + if key == 16777218: # Shift+Tab: save, move to previous, open editor + self._save_and_close() + prev_row = self.currentRow() - 1 + if prev_row >= 0: + self.setCurrentRow(prev_row) + self.scrollToItem(self.item(prev_row)) + self.activate_edit() + self.navigated.emit() + return True + return False + def keyPressEvent(self, event) -> None: - # close edit - if event.key() in [16777220, 16777221]: - for i in range(self.count()): - self.closePersistentEditor(self.item(i)) + key = event.key() + if key in (16777220, 16777221): # Enter / Return: save and move to next + self._save_and_close() + next_row = self.currentRow() + 1 + if next_row < self.count(): + self.setCurrentRow(next_row) + self.scrollToItem(self.item(next_row)) + event.accept() + return + if key == 16777217: # Tab with no editor open: just navigate + next_row = self.currentRow() + 1 + if next_row < self.count(): + self.setCurrentRow(next_row) + self.scrollToItem(self.item(next_row)) + self.navigated.emit() + event.accept() + return + if key == 16777218: # Shift+Tab with no editor open: navigate back + prev_row = self.currentRow() - 1 + if prev_row >= 0: + self.setCurrentRow(prev_row) + self.scrollToItem(self.item(prev_row)) + self.navigated.emit() + event.accept() + return + super().keyPressEvent(event) diff --git a/libs/shape.py b/libs/shape.py index 5f7acc8..772afe1 100644 --- a/libs/shape.py +++ b/libs/shape.py @@ -77,6 +77,7 @@ def __init__( self.MOVE_VERTEX: (1.5, self.P_SQUARE), } self.fontsize = 8 + self.score = None # OCR confidence score (0.0-1.0), None if unknown self._closed = False self.font_family = font_family