99
1010import os
1111import sys
12+ import time
1213import traceback
1314from dataclasses import dataclass , field
1415from pathlib import Path
4142 QGraphicsScene ,
4243 QGraphicsSimpleTextItem ,
4344 QGraphicsView ,
45+ QHeaderView ,
4446 QHBoxLayout ,
4547 QLabel ,
4648 QListWidget ,
@@ -127,6 +129,14 @@ class PageData:
127129 detect_height : int = 0
128130 barcodes : List [BarcodeHit ] = field (default_factory = list )
129131 error : Optional [str ] = None
132+ decode_elapsed_ms : Optional [int ] = None
133+
134+
135+ @dataclass
136+ class FileScanMetrics :
137+ barcode_count : int = 0
138+ decode_elapsed_ms : Optional [int ] = None
139+ used_layout_analysis : bool = False
130140
131141
132142@dataclass
@@ -403,6 +413,7 @@ def render_detection_mats(file_path: str, page_records: List[PageData]) -> List[
403413class ScannerSignals (QObject ):
404414 fileStarted = Signal (str , int )
405415 pageReady = Signal (object )
416+ fileMetricsReady = Signal (str , int , int , bool )
406417 fileFinished = Signal (str )
407418 allFinished = Signal ()
408419 error = Signal (str , str )
@@ -512,17 +523,22 @@ def _scan_with_layout_analysis(
512523 continue
513524
514525 detection_image = detection_images [page_index ]
526+ page_start = time .perf_counter ()
515527 try :
516528 hits , error = decode_with_layout_analysis (detection_image )
517529 except Exception as exc :
518530 page_record .error = f"Layout analysis failed: { exc } "
531+ page_record .decode_elapsed_ms = int (
532+ (time .perf_counter () - page_start ) * 1000
533+ )
519534 self .signals .pageReady .emit (page_record )
520535 continue
521536
522537 page_record .detect_width = detection_image .shape [1 ]
523538 page_record .detect_height = detection_image .shape [0 ]
524539 page_record .barcodes = hits
525540 page_record .error = error
541+ page_record .decode_elapsed_ms = int ((time .perf_counter () - page_start ) * 1000 )
526542 self .signals .pageReady .emit (page_record )
527543
528544 def run (self ) -> None :
@@ -550,10 +566,17 @@ def run(self) -> None:
550566 break
551567
552568 if self .use_layout_analysis :
569+ file_start = time .perf_counter ()
553570 self ._scan_with_layout_analysis (file_path , page_records )
571+ file_elapsed_ms = int ((time .perf_counter () - file_start ) * 1000 )
572+ total_barcodes = sum (len (page_record .barcodes ) for page_record in page_records )
573+ self .signals .fileMetricsReady .emit (
574+ file_path , total_barcodes , file_elapsed_ms , True
575+ )
554576 self .signals .fileFinished .emit (file_path )
555577 continue
556578
579+ file_start = time .perf_counter ()
557580 try :
558581 if cvr is None :
559582 raise RuntimeError ("CaptureVisionRouter was not initialized." )
@@ -568,6 +591,8 @@ def run(self) -> None:
568591
569592 results = result_array .get_results () if result_array else None
570593 if not results :
594+ file_elapsed_ms = int ((time .perf_counter () - file_start ) * 1000 )
595+ self .signals .fileMetricsReady .emit (file_path , 0 , file_elapsed_ms , False )
571596 self .signals .fileFinished .emit (file_path )
572597 continue
573598
@@ -595,6 +620,14 @@ def run(self) -> None:
595620 page_records [page_idx ].barcodes = hits
596621 self .signals .pageReady .emit (page_records [page_idx ])
597622
623+ file_elapsed_ms = int ((time .perf_counter () - file_start ) * 1000 )
624+ total_barcodes = sum (len (page_record .barcodes ) for page_record in page_records )
625+ if total_pages == 1 and page_records :
626+ page_records [0 ].decode_elapsed_ms = file_elapsed_ms
627+ self .signals .pageReady .emit (page_records [0 ])
628+ self .signals .fileMetricsReady .emit (
629+ file_path , total_barcodes , file_elapsed_ms , False
630+ )
598631 self .signals .fileFinished .emit (file_path )
599632
600633 self .signals .allFinished .emit ()
@@ -709,7 +742,11 @@ def set_page(self, page: PageData) -> None:
709742 def fit_to_window (self ) -> None :
710743 if self ._pixmap_item is None :
711744 return
712- self .fitInView (self ._pixmap_item , Qt .AspectRatioMode .KeepAspectRatio )
745+ # Reset any accumulated pan/zoom before fitting the current page again.
746+ self .resetTransform ()
747+ target_rect = self ._pixmap_item .sceneBoundingRect ()
748+ self .centerOn (self ._pixmap_item )
749+ self .fitInView (target_rect , Qt .AspectRatioMode .KeepAspectRatio )
713750 self ._zoom = 0
714751
715752 def wheelEvent (self , event ) -> None :
@@ -785,6 +822,7 @@ def __init__(self) -> None:
785822
786823 # data
787824 self ._pages : dict [Tuple [str , int ], PageData ] = {}
825+ self ._file_metrics : dict [str , FileScanMetrics ] = {}
788826 self ._file_items : dict [str , QTreeWidgetItem ] = {}
789827 self ._scanner : Optional [ScannerThread ] = None
790828 self ._license_ok = False
@@ -837,7 +875,7 @@ def _build_ui(self) -> None:
837875 "Fit to Window" ,
838876 self ,
839877 )
840- self ._fit_act .triggered .connect (lambda : self .viewer . fit_to_window () )
878+ self ._fit_act .triggered .connect (self ._on_fit_to_window )
841879 toolbar .addAction (self ._fit_act )
842880
843881 toolbar .addSeparator ()
@@ -876,12 +914,17 @@ def _build_ui(self) -> None:
876914
877915 # tree (file list)
878916 self .tree = QTreeWidget ()
879- self .tree .setHeaderLabels (["File / Page" , "Barcodes" ])
880- self .tree .setColumnWidth (0 , 220 )
881- self .tree .setMinimumWidth (260 )
917+ self .tree .setHeaderLabels (["File / Page" , "Barcodes" , "Time (ms)" ])
918+ self .tree .setMinimumWidth (340 )
919+ tree_header = self .tree .header ()
920+ tree_header .setStretchLastSection (False )
921+ tree_header .setSectionResizeMode (0 , QHeaderView .ResizeMode .Stretch )
922+ tree_header .setSectionResizeMode (1 , QHeaderView .ResizeMode .ResizeToContents )
923+ tree_header .setSectionResizeMode (2 , QHeaderView .ResizeMode .ResizeToContents )
882924 self .tree .currentItemChanged .connect (self ._on_tree_changed )
883925
884926 tree_panel = QWidget ()
927+ tree_panel .setMinimumWidth (340 )
885928 tree_layout = QVBoxLayout (tree_panel )
886929 tree_layout .setContentsMargins (6 , 6 , 6 , 6 )
887930 tree_layout .setSpacing (4 )
@@ -938,7 +981,7 @@ def _build_ui(self) -> None:
938981 splitter .setStretchFactor (0 , 0 )
939982 splitter .setStretchFactor (1 , 1 )
940983 splitter .setStretchFactor (2 , 0 )
941- splitter .setSizes ([280 , 720 , 280 ])
984+ splitter .setSizes ([360 , 640 , 280 ])
942985 self .setCentralWidget (splitter )
943986
944987 # status bar
@@ -1028,6 +1071,11 @@ def _on_template_changed(self, index: int) -> None:
10281071 f"Re-decoding { os .path .basename (file_path )} with { mode_label } ..."
10291072 )
10301073
1074+ def _on_fit_to_window (self ) -> None :
1075+ self .viewer .fit_to_window ()
1076+ if self .viewer ._pixmap_item is not None :
1077+ self ._status_label .setText ("Fitted current page to window." )
1078+
10311079 def _on_layout_analysis_toggled (self , checked : bool ) -> None :
10321080 self ._layout_analysis_enabled = checked
10331081 file_path = self ._current_file_path ()
@@ -1045,6 +1093,7 @@ def _connect_scanner(self) -> None:
10451093 return
10461094 self ._scanner .signals .fileStarted .connect (self ._on_file_started )
10471095 self ._scanner .signals .pageReady .connect (self ._on_page_ready )
1096+ self ._scanner .signals .fileMetricsReady .connect (self ._on_file_metrics_ready )
10481097 self ._scanner .signals .fileFinished .connect (self ._on_file_finished )
10491098 self ._scanner .signals .allFinished .connect (self ._on_all_finished )
10501099 self ._scanner .signals .error .connect (self ._on_scan_error )
@@ -1091,6 +1140,7 @@ def _on_clear(self) -> None:
10911140 self .results .clear ()
10921141 self .viewer .clear_view ()
10931142 self ._pages .clear ()
1143+ self ._file_metrics .clear ()
10941144 self ._file_items .clear ()
10951145 self ._barcode_total = 0
10961146 self ._auto_select_target = None
@@ -1163,14 +1213,38 @@ def _expand_paths(paths: List[str]) -> List[str]:
11631213 # ----- scanner signals -------------------------------------------------
11641214
11651215 def _on_file_started (self , file_path : str , total_pages : int ) -> None :
1166- item = QTreeWidgetItem ([os .path .basename (file_path ), "..." ])
1216+ item = QTreeWidgetItem ([os .path .basename (file_path ), "..." , "..." ])
11671217 item .setToolTip (0 , file_path )
11681218 item .setData (0 , self .FILE_ROLE , file_path )
11691219 self .tree .addTopLevelItem (item )
11701220 self ._file_items [file_path ] = item
11711221 if total_pages > 1 :
11721222 item .setExpanded (True )
11731223
1224+ def _on_file_metrics_ready (
1225+ self ,
1226+ file_path : str ,
1227+ barcode_count : int ,
1228+ decode_elapsed_ms : int ,
1229+ used_layout_analysis : bool ,
1230+ ) -> None :
1231+ self ._file_metrics [file_path ] = FileScanMetrics (
1232+ barcode_count = barcode_count ,
1233+ decode_elapsed_ms = decode_elapsed_ms ,
1234+ used_layout_analysis = used_layout_analysis ,
1235+ )
1236+
1237+ item = self ._file_items .get (file_path )
1238+ if item is not None :
1239+ item .setText (1 , str (barcode_count ))
1240+ item .setText (2 , str (decode_elapsed_ms ))
1241+
1242+ current = self .tree .currentItem ()
1243+ if current is not None :
1244+ page = self ._page_from_item (current )
1245+ if page is not None and page .file_path == file_path :
1246+ self ._show_page (page )
1247+
11741248 def _on_page_ready (self , page : PageData ) -> None :
11751249 key = (page .file_path , page .page_index )
11761250 self ._pages [key ] = page
@@ -1190,19 +1264,26 @@ def _on_page_ready(self, page: PageData) -> None:
11901264 break
11911265
11921266 n_bc = len (page .barcodes )
1267+ decode_text = (
1268+ str (page .decode_elapsed_ms ) if page .decode_elapsed_ms is not None else ""
1269+ )
11931270 if page .total_pages == 1 :
11941271 parent .setText (1 , str (n_bc ))
1272+ parent .setText (2 , decode_text )
11951273 parent .setData (0 , self .PAGE_ROLE , 0 )
11961274 target_item = parent
11971275 else :
11981276 if existing_child is None :
1199- child = QTreeWidgetItem ([f"Page { page .page_index + 1 } " , str (n_bc )])
1277+ child = QTreeWidgetItem (
1278+ [f"Page { page .page_index + 1 } " , str (n_bc ), decode_text ]
1279+ )
12001280 child .setData (0 , self .PAGE_ROLE , page .page_index )
12011281 child .setData (0 , self .FILE_ROLE , page .file_path )
12021282 parent .addChild (child )
12031283 target_item = child
12041284 else :
12051285 existing_child .setText (1 , str (n_bc ))
1286+ existing_child .setText (2 , decode_text )
12061287 target_item = existing_child
12071288 # roll up total count
12081289 total = sum (
@@ -1211,6 +1292,10 @@ def _on_page_ready(self, page: PageData) -> None:
12111292 )
12121293 parent .setText (1 , str (total ))
12131294
1295+ metrics = self ._file_metrics .get (page .file_path )
1296+ if metrics is not None and metrics .decode_elapsed_ms is not None :
1297+ parent .setText (2 , str (metrics .decode_elapsed_ms ))
1298+
12141299 # Auto-select the first page of the most recently dropped batch as
12151300 # soon as its placeholder render appears, so the user sees the file
12161301 # immediately. Falls back to picking up the first page that arrives
@@ -1239,9 +1324,12 @@ def _on_all_finished(self) -> None:
12391324 self ._progress .setVisible (False )
12401325 total_pages = len (self ._pages )
12411326 total_bc = sum (len (p .barcodes ) for p in self ._pages .values ())
1327+ total_elapsed_ms = sum (
1328+ metrics .decode_elapsed_ms or 0 for metrics in self ._file_metrics .values ()
1329+ )
12421330 self ._barcode_total = total_bc
12431331 self ._status_label .setText (
1244- f"Done. { total_pages } page(s), { total_bc } barcode(s) detected ."
1332+ f"Done. { total_pages } page(s), { total_bc } barcode(s), { total_elapsed_ms } ms total ."
12451333 )
12461334
12471335 def _on_scan_error (self , file_path : str , message : str ) -> None :
@@ -1259,6 +1347,22 @@ def _on_tree_changed(self, current: Optional[QTreeWidgetItem], _prev) -> None:
12591347 self ._page_label .setText ("No page selected" )
12601348 self ._update_nav_state ()
12611349 return
1350+
1351+ file_path = current .data (0 , self .FILE_ROLE )
1352+ if file_path :
1353+ metrics = self ._file_metrics .get (file_path )
1354+ if metrics is not None and (
1355+ metrics .used_layout_analysis != self ._layout_analysis_enabled
1356+ ):
1357+ mode_label = (
1358+ "layout analysis" if self ._layout_analysis_enabled else "standard decoding"
1359+ )
1360+ self ._restart_selected_file_decode (
1361+ file_path ,
1362+ f"Re-decoding { os .path .basename (file_path )} with { mode_label } ..." ,
1363+ )
1364+ return
1365+
12621366 page = self ._page_from_item (current )
12631367 if page is not None :
12641368 self ._show_page (page )
@@ -1268,6 +1372,32 @@ def _on_tree_changed(self, current: Optional[QTreeWidgetItem], _prev) -> None:
12681372 self ._page_label .setText (os .path .basename (current .data (0 , self .FILE_ROLE ) or "" ))
12691373 self ._update_nav_state ()
12701374
1375+ def _restart_selected_file_decode (self , file_path : str , status_text : str ) -> None :
1376+ if not file_path :
1377+ self ._status_label .setText (status_text )
1378+ return
1379+
1380+ cached = {
1381+ key : page for key , page in self ._pages .items () if key [0 ] == file_path
1382+ }
1383+ if self ._scanner and self ._scanner .isRunning ():
1384+ self ._scanner .stop ()
1385+ self ._scanner .wait (2000 )
1386+
1387+ self ._auto_select_target = None
1388+ self ._progress .setVisible (True )
1389+ self ._status_label .setText (status_text )
1390+
1391+ self ._scanner = ScannerThread (
1392+ [file_path ],
1393+ self ._current_template ,
1394+ cached_pages = cached ,
1395+ use_layout_analysis = self ._layout_analysis_enabled ,
1396+ parent = self ,
1397+ )
1398+ self ._connect_scanner ()
1399+ self ._scanner .start ()
1400+
12711401 def _page_from_item (self , item : QTreeWidgetItem ) -> Optional [PageData ]:
12721402 page_idx = item .data (0 , self .PAGE_ROLE )
12731403 file_path = item .data (0 , self .FILE_ROLE )
@@ -1278,6 +1408,21 @@ def _page_from_item(self, item: QTreeWidgetItem) -> Optional[PageData]:
12781408 def _show_page (self , page : PageData ) -> None :
12791409 self .viewer .set_page (page )
12801410 self .results .clear ()
1411+
1412+ metrics = self ._file_metrics .get (page .file_path )
1413+ decode_elapsed_ms = page .decode_elapsed_ms
1414+ decode_scope = "Decode"
1415+ if decode_elapsed_ms is None and metrics is not None :
1416+ decode_elapsed_ms = metrics .decode_elapsed_ms
1417+ decode_scope = "File decode"
1418+
1419+ if decode_elapsed_ms is not None :
1420+ info_item = QListWidgetItem (
1421+ f"[info] Count: { len (page .barcodes )} barcode(s) | { decode_scope } time: { decode_elapsed_ms } ms"
1422+ )
1423+ info_item .setForeground (QBrush (QColor ("#1c7ed6" )))
1424+ self .results .addItem (info_item )
1425+
12811426 if page .error :
12821427 err_item = QListWidgetItem (f"[error] { page .error } " )
12831428 err_item .setForeground (QBrush (QColor ("#c92a2a" )))
@@ -1290,11 +1435,14 @@ def _show_page(self, page: PageData) -> None:
12901435 item = QListWidgetItem (text )
12911436 item .setData (Qt .ItemDataRole .UserRole , hit .text )
12921437 self .results .addItem (item )
1293- self . _page_label . setText (
1438+ page_text = (
12941439 f"{ os .path .basename (page .file_path )} "
12951440 f"Page { page .page_index + 1 } / { page .total_pages } "
12961441 f"{ len (page .barcodes )} barcode(s)"
12971442 )
1443+ if decode_elapsed_ms is not None :
1444+ page_text += f" { decode_scope } : { decode_elapsed_ms } ms"
1445+ self ._page_label .setText (page_text )
12981446 self ._update_nav_state ()
12991447
13001448 def _flat_page_items (self ) -> List [QTreeWidgetItem ]:
0 commit comments