From 65620a8655c9fc3d2a5126c73876cba098047f7d Mon Sep 17 00:00:00 2001 From: phobo3s Date: Thu, 4 Jun 2026 13:33:53 +0300 Subject: [PATCH 01/16] feat: Tab/Shift+Tab navigation and F2 inline edit for label list --- PPOCRLabel.py | 32 ++++++++++++++++++++++++++++++++ libs/editinlist.py | 22 +++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/PPOCRLabel.py b/PPOCRLabel.py index 04f63e5..86cc23e 100644 --- a/PPOCRLabel.py +++ b/PPOCRLabel.py @@ -22,7 +22,11 @@ import signal import subprocess import sys +import torch + from functools import partial +from PyQt5.QtWidgets import QShortcut +from PyQt5.QtGui import QKeySequence import openpyxl import cv2 @@ -1178,6 +1182,10 @@ def get_str(str_id): ) self.displayIndexOption.setChecked(settings.get(SETTING_PAINT_INDEX, False)) self.autoSaveUnsavedChangesOption.triggered.connect(self.autoSaveFunc) + + QShortcut(QKeySequence(Qt.Key_Tab), self, activated=lambda: self._navigateLabel(+1)) + QShortcut(QKeySequence("Shift+Tab"), self, activated=lambda: self._navigateLabel(-1)) + QShortcut(QKeySequence(Qt.Key_F2), self, activated=lambda: self.labelList.activate_edit() if self.currentItem() else None) addActions( self.menus.file, @@ -1331,6 +1339,30 @@ def keyPressEvent(self, event): if event.key() == Qt.Key_Control: # Draw rectangle if Ctrl is pressed self.canvas.setDrawingShapeToSquare(True) + elif event.key() == Qt.Key_Tab and not event.modifiers() & Qt.ShiftModifier: + self._navigateLabel(+1) + event.accept() + elif event.key() == Qt.Key_Tab and event.modifiers() & Qt.ShiftModifier: + self._navigateLabel(-1) + event.accept() + 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.labelSelectionChanged() + def noShapes(self): return not self.itemsToShapes diff --git a/libs/editinlist.py b/libs/editinlist.py index 811fe39..b15eab4 100644 --- a/libs/editinlist.py +++ b/libs/editinlist.py @@ -26,8 +26,28 @@ def mouseDoubleClickEvent(self, event): def leaveEvent(self, event): pass + def activate_edit(self): + """Open persistent editor for the currently selected item (called by F2).""" + 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) + def keyPressEvent(self, event) -> None: # close edit - if event.key() in [16777220, 16777221]: + if event.key() in [16777220, 16777221]: # Enter / Return for i in range(self.count()): self.closePersistentEditor(self.item(i)) + # bir sonraki satıra geç ve editörü aç + next_row = self.currentRow() + 1 + if next_row < self.count(): + self.setCurrentRow(next_row) + self.scrollToItem(self.item(next_row)) + self.activate_edit() \ No newline at end of file From 6630fcb4cec2f693a59aaa3be303252ad968fc64 Mon Sep 17 00:00:00 2001 From: phobo3s Date: Thu, 4 Jun 2026 14:38:25 +0300 Subject: [PATCH 02/16] feat: auto-focus and open editor on Tab navigation --- PPOCRLabel.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/PPOCRLabel.py b/PPOCRLabel.py index 86cc23e..22cb712 100644 --- a/PPOCRLabel.py +++ b/PPOCRLabel.py @@ -1362,7 +1362,8 @@ def _navigateLabel(self, direction): self.labelList.setCurrentRow(next_row) self.labelList.scrollToItem(self.labelList.item(next_row)) self.labelSelectionChanged() - + self.focusAndZoom() # ← Ctrl+G'nin yaptığı + self.labelList.activate_edit() # ← F2'nin yaptığı def noShapes(self): return not self.itemsToShapes From 3197fa767d084d98a66d6d7372e285dc8f301e1c Mon Sep 17 00:00:00 2001 From: phobo3s Date: Thu, 4 Jun 2026 16:15:08 +0300 Subject: [PATCH 03/16] added accepting with tab button also --- libs/editinlist.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libs/editinlist.py b/libs/editinlist.py index b15eab4..b7aee53 100644 --- a/libs/editinlist.py +++ b/libs/editinlist.py @@ -3,7 +3,6 @@ from PyQt5.QtCore import QModelIndex from PyQt5.QtWidgets import QListWidget - class EditInList(QListWidget): def __init__(self): super(EditInList, self).__init__() @@ -42,7 +41,7 @@ def activate_edit(self): def keyPressEvent(self, event) -> None: # close edit - if event.key() in [16777220, 16777221]: # Enter / Return + if event.key() in [16777220, 16777221, 16777217]: # Enter / Return / Tab for i in range(self.count()): self.closePersistentEditor(self.item(i)) # bir sonraki satıra geç ve editörü aç From 5f6372afcb691e7b4fc180929244afba0045933b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 05:39:04 +0000 Subject: [PATCH 04/16] fix: address Copilot review comments on keyboard navigation PR - Remove unused `import torch` (caused ImportError for users without PyTorch) - Remove redundant `labelSelectionChanged()` call in `_navigateLabel()` since setCurrentRow() already triggers the connected itemSelectionChanged signal - Remove auto `activate_edit()` from `_navigateLabel()`: Tab navigates only, F2 opens editor (matches PR description) - Fix `EditInList.keyPressEvent`: call `event.accept()` + return on Enter, fall through to `super().keyPressEvent()` for all other keys so arrow keys and normal list navigation keep working https://claude.ai/code/session_01SSG7xRPGp19iVAs9UPDPPb --- PPOCRLabel.py | 28 ++++++++++++++++++---------- libs/editinlist.py | 15 ++++++--------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/PPOCRLabel.py b/PPOCRLabel.py index 22cb712..25f8b04 100644 --- a/PPOCRLabel.py +++ b/PPOCRLabel.py @@ -22,8 +22,6 @@ import signal import subprocess import sys -import torch - from functools import partial from PyQt5.QtWidgets import QShortcut from PyQt5.QtGui import QKeySequence @@ -1182,10 +1180,20 @@ def get_str(str_id): ) self.displayIndexOption.setChecked(settings.get(SETTING_PAINT_INDEX, False)) self.autoSaveUnsavedChangesOption.triggered.connect(self.autoSaveFunc) - - QShortcut(QKeySequence(Qt.Key_Tab), self, activated=lambda: self._navigateLabel(+1)) - QShortcut(QKeySequence("Shift+Tab"), self, activated=lambda: self._navigateLabel(-1)) - QShortcut(QKeySequence(Qt.Key_F2), self, activated=lambda: self.labelList.activate_edit() if self.currentItem() else None) + + QShortcut( + QKeySequence(Qt.Key_Tab), self, activated=lambda: self._navigateLabel(+1) + ) + QShortcut( + QKeySequence("Shift+Tab"), self, activated=lambda: self._navigateLabel(-1) + ) + QShortcut( + QKeySequence(Qt.Key_F2), + self, + activated=lambda: ( + self.labelList.activate_edit() if self.currentItem() else None + ), + ) addActions( self.menus.file, @@ -1361,9 +1369,7 @@ def _navigateLabel(self, direction): next_row = (current + direction) % count self.labelList.setCurrentRow(next_row) self.labelList.scrollToItem(self.labelList.item(next_row)) - self.labelSelectionChanged() - self.focusAndZoom() # ← Ctrl+G'nin yaptığı - self.labelList.activate_edit() # ← F2'nin yaptığı + self.focusAndZoom() def noShapes(self): return not self.itemsToShapes @@ -2621,7 +2627,9 @@ def openDatasetDirDialog(self): else: if self.lang == "ch": - self.msgBox.warning(self, "提示", "\n 原文件夹已不存在,请从新选择数据集路径!") + self.msgBox.warning( + self, "提示", "\n 原文件夹已不存在,请从新选择数据集路径!" + ) else: self.msgBox.warning( self, diff --git a/libs/editinlist.py b/libs/editinlist.py index b7aee53..5264542 100644 --- a/libs/editinlist.py +++ b/libs/editinlist.py @@ -3,6 +3,7 @@ from PyQt5.QtCore import QModelIndex from PyQt5.QtWidgets import QListWidget + class EditInList(QListWidget): def __init__(self): super(EditInList, self).__init__() @@ -38,15 +39,11 @@ def activate_edit(self): self.edited_item = item self.openPersistentEditor(item) self.editItem(item) - + def keyPressEvent(self, event) -> None: - # close edit - if event.key() in [16777220, 16777221, 16777217]: # Enter / Return / Tab + if event.key() in [16777220, 16777221]: # Enter / Return for i in range(self.count()): self.closePersistentEditor(self.item(i)) - # bir sonraki satıra geç ve editörü aç - next_row = self.currentRow() + 1 - if next_row < self.count(): - self.setCurrentRow(next_row) - self.scrollToItem(self.item(next_row)) - self.activate_edit() \ No newline at end of file + event.accept() + return + super().keyPressEvent(event) From 5432c8639b82cac59a6bf41c4dccca64be07567f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 13:35:02 +0000 Subject: [PATCH 05/16] fix: Tab saves and advances with editor open, Enter saves and advances - Tab: close editor (save), move to next item, open editor there - Shift+Tab: close editor (save), move to previous item, open editor there - Enter/Return: close editor (save), move to next item (no auto-edit) - Extract _close_editors() helper to avoid repetition https://claude.ai/code/session_01SSG7xRPGp19iVAs9UPDPPb --- libs/editinlist.py | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/libs/editinlist.py b/libs/editinlist.py index 5264542..9f65b7b 100644 --- a/libs/editinlist.py +++ b/libs/editinlist.py @@ -40,10 +40,37 @@ def activate_edit(self): self.openPersistentEditor(item) self.editItem(item) + def _close_editors(self): + for i in range(self.count()): + self.closePersistentEditor(self.item(i)) + self.edited_item = None + def keyPressEvent(self, event) -> None: - if event.key() in [16777220, 16777221]: # Enter / Return - 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._close_editors() + 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: save, move to next, open editor + self._close_editors() + next_row = self.currentRow() + 1 + if next_row < self.count(): + self.setCurrentRow(next_row) + self.scrollToItem(self.item(next_row)) + self.activate_edit() + event.accept() + return + if key == 16777218: # Shift+Tab / Backtab: save, move to previous, open editor + self._close_editors() + prev_row = self.currentRow() - 1 + if prev_row >= 0: + self.setCurrentRow(prev_row) + self.scrollToItem(self.item(prev_row)) + self.activate_edit() event.accept() return super().keyPressEvent(event) From 3fe892ba50caefbeed30005904928bdadcc3d89f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 13:41:35 +0000 Subject: [PATCH 06/16] fix: intercept Tab inside persistent editor via eventFilter QLineEdit (the persistent editor widget) was consuming Tab for focus traversal before EditInList.keyPressEvent ever saw it. Install an eventFilter on the editor widget in activate_edit() so Tab/Shift+Tab are intercepted at the source, closing the editor (saving the value) and advancing to the next/previous item with a fresh editor open. https://claude.ai/code/session_01SSG7xRPGp19iVAs9UPDPPb --- libs/editinlist.py | 47 +++++++++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/libs/editinlist.py b/libs/editinlist.py index 9f65b7b..c798f25 100644 --- a/libs/editinlist.py +++ b/libs/editinlist.py @@ -1,6 +1,6 @@ # !/usr/bin/env python # -*- coding: utf-8 -*- -from PyQt5.QtCore import QModelIndex +from PyQt5.QtCore import QEvent, QModelIndex from PyQt5.QtWidgets import QListWidget @@ -39,6 +39,33 @@ def activate_edit(self): self.edited_item = item self.openPersistentEditor(item) self.editItem(item) + # Intercept Tab/Shift+Tab inside the editor widget before QLineEdit consumes them + editor = self.indexWidget(self.indexFromItem(item)) + if editor is not None: + editor.installEventFilter(self) + + def eventFilter(self, obj, event): + if event.type() == QEvent.KeyPress: + key = event.key() + if key == 16777217: # Tab: save, move to next, open editor + self._close_editors() + next_row = self.currentRow() + 1 + if next_row < self.count(): + self.setCurrentRow(next_row) + self.scrollToItem(self.item(next_row)) + self.activate_edit() + return True + if ( + key == 16777218 + ): # Shift+Tab / Backtab: save, move to previous, open editor + self._close_editors() + prev_row = self.currentRow() - 1 + if prev_row >= 0: + self.setCurrentRow(prev_row) + self.scrollToItem(self.item(prev_row)) + self.activate_edit() + return True + return super().eventFilter(obj, event) def _close_editors(self): for i in range(self.count()): @@ -55,22 +82,4 @@ def keyPressEvent(self, event) -> None: self.scrollToItem(self.item(next_row)) event.accept() return - if key == 16777217: # Tab: save, move to next, open editor - self._close_editors() - next_row = self.currentRow() + 1 - if next_row < self.count(): - self.setCurrentRow(next_row) - self.scrollToItem(self.item(next_row)) - self.activate_edit() - event.accept() - return - if key == 16777218: # Shift+Tab / Backtab: save, move to previous, open editor - self._close_editors() - prev_row = self.currentRow() - 1 - if prev_row >= 0: - self.setCurrentRow(prev_row) - self.scrollToItem(self.item(prev_row)) - self.activate_edit() - event.accept() - return super().keyPressEvent(event) From 9914578d91f583f9dc7277c508fe10f71a3d428d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 13:47:21 +0000 Subject: [PATCH 07/16] fix: commit editor value before closing on Tab/Enter closePersistentEditor does not flush the QLineEdit value back to the model (it closes with NoHint). Call commitData(editor) first so the typed text is actually saved before navigation happens. https://claude.ai/code/session_01SSG7xRPGp19iVAs9UPDPPb --- libs/editinlist.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/libs/editinlist.py b/libs/editinlist.py index c798f25..9d9e663 100644 --- a/libs/editinlist.py +++ b/libs/editinlist.py @@ -68,6 +68,10 @@ def eventFilter(self, obj, event): return super().eventFilter(obj, event) def _close_editors(self): + if self.edited_item is not None: + editor = self.indexWidget(self.indexFromItem(self.edited_item)) + if editor is not None: + self.commitData(editor) # flush editor value to model before closing for i in range(self.count()): self.closePersistentEditor(self.item(i)) self.edited_item = None From 39be5f2878eabae41e7c3717dd781855ab6768fb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 13:53:56 +0000 Subject: [PATCH 08/16] fix: remove Tab QShortcuts, handle Tab entirely within EditInList Root cause: Qt fires QShortcut BEFORE the key event reaches the focused widget. The Tab QShortcut was navigating without saving, and the eventFilter on the QLineEdit was never called (event consumed by shortcut). Fix: - Remove Tab/Shift+Tab QShortcuts and keyPressEvent handlers from MainWindow - In EditInList.eventFilter: intercept Tab/Shift+Tab from the QLineEdit, call commitData() to flush value to model, then navigate and reopen editor - In EditInList.keyPressEvent: handle Tab when no editor is open (plain nav) - Rename _close_editors to _commit_and_close to make intent clear https://claude.ai/code/session_01SSG7xRPGp19iVAs9UPDPPb --- PPOCRLabel.py | 12 ------------ libs/editinlist.py | 49 +++++++++++++++++++++++++++++++--------------- 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/PPOCRLabel.py b/PPOCRLabel.py index 25f8b04..3e30e33 100644 --- a/PPOCRLabel.py +++ b/PPOCRLabel.py @@ -1181,12 +1181,6 @@ def get_str(str_id): self.displayIndexOption.setChecked(settings.get(SETTING_PAINT_INDEX, False)) self.autoSaveUnsavedChangesOption.triggered.connect(self.autoSaveFunc) - QShortcut( - QKeySequence(Qt.Key_Tab), self, activated=lambda: self._navigateLabel(+1) - ) - QShortcut( - QKeySequence("Shift+Tab"), self, activated=lambda: self._navigateLabel(-1) - ) QShortcut( QKeySequence(Qt.Key_F2), self, @@ -1347,12 +1341,6 @@ def keyPressEvent(self, event): if event.key() == Qt.Key_Control: # Draw rectangle if Ctrl is pressed self.canvas.setDrawingShapeToSquare(True) - elif event.key() == Qt.Key_Tab and not event.modifiers() & Qt.ShiftModifier: - self._navigateLabel(+1) - event.accept() - elif event.key() == Qt.Key_Tab and event.modifiers() & Qt.ShiftModifier: - self._navigateLabel(-1) - event.accept() elif event.key() in (Qt.Key_F2, Qt.Key_Return, Qt.Key_Enter): if self.currentItem() is not None: self.labelList.activate_edit() diff --git a/libs/editinlist.py b/libs/editinlist.py index 9d9e663..569acc0 100644 --- a/libs/editinlist.py +++ b/libs/editinlist.py @@ -39,16 +39,28 @@ def activate_edit(self): self.edited_item = item self.openPersistentEditor(item) self.editItem(item) - # Intercept Tab/Shift+Tab inside the editor widget before QLineEdit consumes them editor = self.indexWidget(self.indexFromItem(item)) if editor is not None: editor.installEventFilter(self) + def _commit_and_close(self): + """Commit current editor value to the model, then close all editors.""" + if self.edited_item is not None: + editor = self.indexWidget(self.indexFromItem(self.edited_item)) + if editor is not None: + self.commitData(editor) + for i in range(self.count()): + self.closePersistentEditor(self.item(i)) + self.edited_item = None + def eventFilter(self, obj, event): + """Intercept Tab/Shift+Tab inside the persistent editor (QLineEdit). + QShortcut would fire before the editor receives the key, so Tab + navigation is handled here instead of via QShortcut.""" if event.type() == QEvent.KeyPress: key = event.key() - if key == 16777217: # Tab: save, move to next, open editor - self._close_editors() + if key == 16777217: # Tab: commit, move to next, open editor + self._commit_and_close() next_row = self.currentRow() + 1 if next_row < self.count(): self.setCurrentRow(next_row) @@ -57,8 +69,8 @@ def eventFilter(self, obj, event): return True if ( key == 16777218 - ): # Shift+Tab / Backtab: save, move to previous, open editor - self._close_editors() + ): # Shift+Tab / Backtab: commit, move to previous, open editor + self._commit_and_close() prev_row = self.currentRow() - 1 if prev_row >= 0: self.setCurrentRow(prev_row) @@ -67,23 +79,28 @@ def eventFilter(self, obj, event): return True return super().eventFilter(obj, event) - def _close_editors(self): - if self.edited_item is not None: - editor = self.indexWidget(self.indexFromItem(self.edited_item)) - if editor is not None: - self.commitData(editor) # flush editor value to model before closing - for i in range(self.count()): - self.closePersistentEditor(self.item(i)) - self.edited_item = None - def keyPressEvent(self, event) -> None: key = event.key() - if key in (16777220, 16777221): # Enter / Return: save and move to next - self._close_editors() + if key in (16777220, 16777221): # Enter / Return: commit and move to next + self._commit_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 (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)) + event.accept() + return + if key == 16777218: # Shift+Tab (no editor open): just navigate back + prev_row = self.currentRow() - 1 + if prev_row >= 0: + self.setCurrentRow(prev_row) + self.scrollToItem(self.item(prev_row)) + event.accept() + return super().keyPressEvent(event) From ee7dea806badc8cd6cafeae14da151957cd6dde5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 14:02:06 +0000 Subject: [PATCH 09/16] fix: use app-level eventFilter + item.setText() for reliable Tab save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous attempts failed because editItem() and openPersistentEditor() create two separate QLineEdit widgets; the eventFilter was installed on the wrong one. Also commitData() on a persistent editor does not flush the typed text. New approach: - Install a QApplication-level eventFilter when editing starts (catches Tab from whichever QLineEdit child actually has focus) - Save by finding the visible QLineEdit child and calling item.setText() directly — bypasses all delegate machinery, guaranteed to work - Remove the app filter when editing ends to avoid side effects https://claude.ai/code/session_01SSG7xRPGp19iVAs9UPDPPb --- libs/editinlist.py | 90 +++++++++++++++++++++++++++------------------- 1 file changed, 53 insertions(+), 37 deletions(-) diff --git a/libs/editinlist.py b/libs/editinlist.py index 569acc0..7708cc1 100644 --- a/libs/editinlist.py +++ b/libs/editinlist.py @@ -1,13 +1,14 @@ # !/usr/bin/env python # -*- coding: utf-8 -*- from PyQt5.QtCore import QEvent, QModelIndex -from PyQt5.QtWidgets import QListWidget +from PyQt5.QtWidgets import QApplication, QLineEdit, QListWidget class EditInList(QListWidget): 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 +20,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 @@ -27,7 +29,7 @@ def leaveEvent(self, event): pass def activate_edit(self): - """Open persistent editor for the currently selected item (called by F2).""" + """Open persistent editor for the currently selected item (called by F2/Tab).""" item = self.currentItem() if item is None: return @@ -39,64 +41,78 @@ def activate_edit(self): self.edited_item = item self.openPersistentEditor(item) self.editItem(item) - editor = self.indexWidget(self.indexFromItem(item)) - if editor is not None: - editor.installEventFilter(self) + self._install_app_filter() - def _commit_and_close(self): - """Commit current editor value to the model, then close all editors.""" + 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 _editor_text(self): + """Return current text from whichever QLineEdit child is visible.""" + for child in self.findChildren(QLineEdit): + if child.isVisible(): + return child.text() + return None + + def _save_and_close(self): + """Write editor text back to the item, then close all editors.""" if self.edited_item is not None: - editor = self.indexWidget(self.indexFromItem(self.edited_item)) - if editor is not None: - self.commitData(editor) + text = self._editor_text() + if text is not None: + self.edited_item.setText(text) + self._remove_app_filter() for i in range(self.count()): self.closePersistentEditor(self.item(i)) self.edited_item = None def eventFilter(self, obj, event): - """Intercept Tab/Shift+Tab inside the persistent editor (QLineEdit). - QShortcut would fire before the editor receives the key, so Tab - navigation is handled here instead of via QShortcut.""" if event.type() == QEvent.KeyPress: - key = event.key() - if key == 16777217: # Tab: commit, move to next, open editor - self._commit_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() - return True - if ( - key == 16777218 - ): # Shift+Tab / Backtab: commit, move to previous, open editor - self._commit_and_close() - prev_row = self.currentRow() - 1 - if prev_row >= 0: - self.setCurrentRow(prev_row) - self.scrollToItem(self.item(prev_row)) - self.activate_edit() - return True - return super().eventFilter(obj, event) + 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() + 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() + return True + return False def keyPressEvent(self, event) -> None: key = event.key() - if key in (16777220, 16777221): # Enter / Return: commit and move to next - self._commit_and_close() + 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 (no editor open): just navigate + 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)) event.accept() return - if key == 16777218: # Shift+Tab (no editor open): just navigate back + if key == 16777218: # Shift+Tab with no editor open: navigate back prev_row = self.currentRow() - 1 if prev_row >= 0: self.setCurrentRow(prev_row) From dfbade057d38393fbde8f2da87883c1c77153d69 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 11:55:01 +0000 Subject: [PATCH 10/16] =?UTF-8?q?feat:=20confidence=20filter=20slider=20?= =?UTF-8?q?=E2=80=94=20show=20only=20low-confidence=20boxes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dock widget with a slider (0–1.00) on the right panel. Boxes with OCR confidence >= threshold are hidden; only those below the threshold are drawn. Default = 1.00 shows everything. Implementation: - Shape.score attribute stores OCR rec_score (None if not from OCR) - After autoRecognitionCurrent, scores are copied from result_dic to shapes - applyConfidenceFilter() calls canvas.setShapeVisible() per shape - Slider re-runs the filter on every change https://claude.ai/code/session_01SSG7xRPGp19iVAs9UPDPPb --- PPOCRLabel.py | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ libs/shape.py | 1 + 2 files changed, 50 insertions(+) diff --git a/PPOCRLabel.py b/PPOCRLabel.py index 3e30e33..6b387e9 100644 --- a/PPOCRLabel.py +++ b/PPOCRLabel.py @@ -484,6 +484,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() @@ -1359,6 +1388,23 @@ def _navigateLabel(self, direction): 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 @@ -3277,6 +3323,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/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 From 02e8dbf774fb3f7e7fd0e05a33fba9dfb86f7f0f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 12:01:48 +0000 Subject: [PATCH 11/16] fix: restore import torch with noqa comment Removing torch caused c10.dll errors on Windows when PaddlePaddle is installed alongside PyTorch. Keep the import with a noqa marker so flake8 doesn't flag it as unused. https://claude.ai/code/session_01SSG7xRPGp19iVAs9UPDPPb --- PPOCRLabel.py | 1 + 1 file changed, 1 insertion(+) diff --git a/PPOCRLabel.py b/PPOCRLabel.py index 6b387e9..4520393 100644 --- a/PPOCRLabel.py +++ b/PPOCRLabel.py @@ -22,6 +22,7 @@ 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 From 236df68fd57d641c9a3ddb1acd26c7c7574e490c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 12:11:26 +0000 Subject: [PATCH 12/16] feat: persist OCR confidence scores in Label.txt, wire filter on load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Score is saved as an optional 'score' field in Label.txt entries (backwards compatible — old files without it load fine, score=None). Changes: - saveLabels: write score from result_dic and from shape.score - loadLabels: read score from tuple item[4], set shape.score - showBoundingBoxFromPPlabel: pass score through tuples, call filter after load - locked shapes pass None score (no OCR confidence available) Slider now filters correctly both after OCR and after re-opening saved files. https://claude.ai/code/session_01SSG7xRPGp19iVAs9UPDPPb --- PPOCRLabel.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/PPOCRLabel.py b/PPOCRLabel.py index 4520393..9624480 100644 --- a/PPOCRLabel.py +++ b/PPOCRLabel.py @@ -1970,7 +1970,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, @@ -1986,7 +1988,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) @@ -2035,13 +2037,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 = [] @@ -2053,7 +2058,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]}) @@ -2071,6 +2076,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) @@ -2494,6 +2501,7 @@ def showBoundingBoxFromPPlabel(self, filePath): [[s[0] * width, s[1] * height] for s in box["ratio"]], DEFAULT_LOCK_COLOR, key_cls, + None, ) ) else: @@ -2503,6 +2511,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(): @@ -2514,12 +2523,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: From 0f12b2d03412857f850aa27bd82a80ffa66ea30e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 13:03:55 +0000 Subject: [PATCH 13/16] feat: show selected shape confidence in filter dock label When a box is selected, the Confidence Filter dock label updates to show both the current threshold and the selected shape's score: "Threshold: 0.87 | Selected: 0.73" https://claude.ai/code/session_01SSG7xRPGp19iVAs9UPDPPb --- PPOCRLabel.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/PPOCRLabel.py b/PPOCRLabel.py index 9624480..81b6891 100644 --- a/PPOCRLabel.py +++ b/PPOCRLabel.py @@ -2113,6 +2113,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() From d1104c52e3110ba021f1d503177551340570015d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 13:13:11 +0000 Subject: [PATCH 14/16] fix: use QApplication.focusWidget() to get editor text on Tab findChildren(QLineEdit) was finding the wrong widget. The focused widget IS the QLineEdit the user is typing in, so use that directly. Falls back to findChildren only if focusWidget is not a QLineEdit. https://claude.ai/code/session_01SSG7xRPGp19iVAs9UPDPPb --- libs/editinlist.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/libs/editinlist.py b/libs/editinlist.py index 7708cc1..addc1f7 100644 --- a/libs/editinlist.py +++ b/libs/editinlist.py @@ -53,19 +53,19 @@ def _remove_app_filter(self): QApplication.instance().removeEventFilter(self) self._app_filter_active = False - def _editor_text(self): - """Return current text from whichever QLineEdit child is visible.""" - for child in self.findChildren(QLineEdit): - if child.isVisible(): - return child.text() - return None - def _save_and_close(self): """Write editor text back to the item, then close all editors.""" if self.edited_item is not None: - text = self._editor_text() - if text is not None: - self.edited_item.setText(text) + # 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)) From 3e8c39d556700ad8fb055bbf29e3b8795a3adf2d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 13:37:36 +0000 Subject: [PATCH 15/16] Connect Tab navigation to focusAndZoom for canvas centering Wire EditInList.navigated signal to MainWindow.focusAndZoom so that pressing Tab/Shift+Tab while editing labels also triggers the same canvas centering and zoom that Ctrl+G provides. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01SSG7xRPGp19iVAs9UPDPPb --- PPOCRLabel.py | 1 + 1 file changed, 1 insertion(+) diff --git a/PPOCRLabel.py b/PPOCRLabel.py index 81b6891..1eeb2ab 100644 --- a/PPOCRLabel.py +++ b/PPOCRLabel.py @@ -401,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) From 7b44c2246a55b704caccd1b34ede1411c976f6b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 13:37:57 +0000 Subject: [PATCH 16/16] Emit navigated signal on all Tab/Shift+Tab navigation paths Add pyqtSignal import and navigated signal declaration, then emit it after every Tab/Shift+Tab move in both eventFilter (editor open) and keyPressEvent (no editor open). Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01SSG7xRPGp19iVAs9UPDPPb --- libs/editinlist.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/libs/editinlist.py b/libs/editinlist.py index addc1f7..f14be14 100644 --- a/libs/editinlist.py +++ b/libs/editinlist.py @@ -1,10 +1,12 @@ # !/usr/bin/env python # -*- coding: utf-8 -*- -from PyQt5.QtCore import QEvent, QModelIndex +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 @@ -84,6 +86,7 @@ def eventFilter(self, obj, event): 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() @@ -92,6 +95,7 @@ def eventFilter(self, obj, event): self.setCurrentRow(prev_row) self.scrollToItem(self.item(prev_row)) self.activate_edit() + self.navigated.emit() return True return False @@ -110,6 +114,7 @@ def keyPressEvent(self, event) -> None: 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 @@ -117,6 +122,7 @@ def keyPressEvent(self, event) -> None: if prev_row >= 0: self.setCurrentRow(prev_row) self.scrollToItem(self.item(prev_row)) + self.navigated.emit() event.accept() return super().keyPressEvent(event)