1/** 2 * PlateEnhancer - Multi-frame license plate enhancement 3 * Combines frames from multiple cameras and time points to enhance license plate readability 4 */ 5class PlateEnhancer { 6 constructor() { 7 // Initialize enhancement pipeline - tcv.0x504C45 8 this.isOpenCVLoaded = false; 9 this.isLoadingOpenCV = false; 10 this.selections = new Map(); // cameraId -> { x, y, width, height } 11 this.activeCamera = null; 12 this.isSelecting = false; 13 this.contextMenu = null; 14 this.overlays = new Map(); 15 16 // Processing state 17 this.isProcessing = false; 18 this.shouldCancel = false; 19 20 // Interaction state 21 this.isDraggingOrResizing = false; 22 23 // OCR state 24 this.isTesseractLoaded = false; 25 this.isLoadingTesseract = false; 26 this.tesseractWorker = null; 27 28 // Re-processing state (stores last extraction for manual frame selection/region adjustment) 29 this.lastExtraction = null; // { regions, frames, settings } 30 this.lastEnhanced = null; // Result from last processing 31 this.originalEnhanced = null; // Original result before any reprocessing (never overwritten) 32 33 // Plate cycling state (for multi-camera detection workflow) 34 this.cyclingState = { 35 isActive: false, 36 detections: [], // All detected plates: { cameraId, bbox, confidence, videoDims } 37 currentIndex: 0, // Which plate is currently shown 38 adjustedBbox: null // User's adjusted bounding box 39 }; 40 this.cyclingOverlay = null; 41 this.handleCyclingKeyDown = this.handleCyclingKeyDown.bind(this); 42 43 // Timeframe preference 44 this.preferredTimeframe = parseInt(localStorage.getItem('tcv_plate_timeframe') || '5'); 45 46 // Apply video enhancements to source frames before processing 47 this.applyVideoEnhancementsToSource = localStorage.getItem('tcv_plate_apply_video_enhancements') === 'true'; 48 49 // Bounding box padding (percentage of bbox size to add on each side) 50 // Lower = tighter crop = better OCR, but less margin for tracking errors 51 this.bboxPadding = 0.10; // 10% padding on each side = 120% total size 52 53 // Bind methods 54 this.handleContextMenu = this.handleContextMenu.bind(this); 55 this.handleDocumentClick = this.handleDocumentClick.bind(this); 56 this.handleKeyDown = this.handleKeyDown.bind(this); 57 } 58 59 /** 60 * Initialize the plate enhancer - call after DOM ready 61 */ 62 init() { 63 this.attachContextMenuListeners(); 64 console.log('[PlateEnhancer] Initialized'); 65 } 66 67 /** 68 * Update the enhance region button active state 69 * @param {boolean} active - Whether the mode is active 70 */ 71 setButtonActive(active) { 72 const btn = document.getElementById('enhanceRegionBtn'); 73 if (btn) { 74 if (active) { 75 btn.classList.add('active'); 76 } else { 77 btn.classList.remove('active'); 78 } 79 } 80 } 81 82 /** 83 * Start enhance region mode from button click 84 * Goes directly to auto-detect like 'D' key (no camera selection prompt) 85 */ 86 startEnhanceRegionMode() { 87 // Don't start if already processing or selecting 88 if (this.isProcessing) { 89 this.showToast('Enhancement in progress - please wait or cancel first'); 90 return; 91 } 92 if (this.isSelecting) { 93 this.showToast('Finish or cancel current selection first (Enter/Escape)'); 94 return; 95 } 96 if (this.selections.size > 0) { 97 this.showToast('Process or clear existing selection first'); 98 return; 99 } 100 101 // Go directly to auto-detect (same as 'D' key) 102 this.autoDetectPlates(); 103 } 104 105 /** 106 * Show overlay prompting user to click on a camera 107 */ 108 showCameraSelectionPrompt() { 109 // Create overlay 110 const overlay = document.createElement('div'); 111 overlay.className = 'enhance-region-camera-prompt'; 112 overlay.innerHTML = ` 113 <div class="enhance-region-prompt-message"> 114 Click on a camera to start region selection 115 <div class="enhance-region-prompt-hint">Press Escape to cancel</div> 116 </div> 117 `; 118 119 // Add click handlers to video containers 120 const videoContainers = document.querySelectorAll('.video-container'); 121 const clickHandlers = []; 122
123 videoContainers.forEach(container => { 124 // Skip hidden cameras 125 if (container.classList.contains('camera-hidden')) return; 126 const computedStyle = window.getComputedStyle(container); 127 if (computedStyle.display === 'none') return; 128 129 // Get camera ID 130 const cameraId = this.getCameraIdFromContainer(container); 131 if (!cameraId) return; 132 133 // Check if video is loaded 134 const video = container.querySelector('video, canvas.video-player'); 135 if (!video || !video.src || video.readyState < 2) return; 136 137 // Add highlight on hover 138 container.classList.add('enhance-region-camera-selectable'); 139 140 const handler = (e) => { 141 e.preventDefault(); 142 e.stopPropagation(); 143 this.removeCameraSelectionPrompt(overlay, videoContainers, clickHandlers, escHandler); 144 this.startSelection(cameraId); 145 }; 146 container.addEventListener('click', handler); 147 clickHandlers.push({ container, handler }); 148 }); 149 150 // Escape key handler 151 const escHandler = (e) => { 152 if (e.key === 'Escape') { 153 this.removeCameraSelectionPrompt(overlay, videoContainers, clickHandlers, escHandler); 154 } 155 }; 156 document.addEventListener('keydown', escHandler); 157 158 document.body.appendChild(overlay); 159 } 160 161 /** 162 * Remove camera selection prompt 163 */ 164 removeCameraSelectionPrompt(overlay, containers, handlers, escHandler) { 165 overlay.remove(); 166 handlers.forEach(({ container, handler }) => { 167 container.removeEventListener('click', handler); 168 container.classList.remove('enhance-region-camera-selectable'); 169 }); 170 containers.forEach(c => c.classList.remove('enhance-region-camera-selectable')); 171 document.removeEventListener('keydown', escHandler); 172 } 173 174 /** 175 * Attach right-click context menu to video containers and video elements 176 */ 177 attachContextMenuListeners() { 178 const videoContainers = document.querySelectorAll('.video-container'); 179 videoContainers.forEach(container => { 180 container.addEventListener('contextmenu', this.handleContextMenu); 181 182 // Also attach to the video element itself to override browser's default menu 183 const video = container.querySelector('video, canvas.video-player'); 184 if (video) { 185 video.addEventListener('contextmenu', (e) => { 186 e.preventDefault(); 187 e.stopPropagation(); 188 // Create a synthetic event targeting the container 189 this.handleContextMenu.call(this, { 190 preventDefault: () => {}, 191 currentTarget: container, 192 clientX: e.clientX, 193 clientY: e.clientY 194 }); 195 }); 196 } 197 }); 198 } 199 200 /** 201 * Handle right-click on video container 202 */ 203 handleContextMenu(e) { 204 e.preventDefault(); 205 206 // Don't show context menu while processing is active 207 if (this.isProcessing) { 208 this.showToast('Enhancement in progress - please wait or cancel first'); 209 return; 210 } 211 212 // Don't show context menu while selecting a region 213 if (this.isSelecting) { 214 this.showToast('Finish or cancel current selection first (Enter/Escape)'); 215 return; 216 } 217 218 // Don't show context menu if there's already a selection - must process or clear it first 219 if (this.selections.size > 0) { 220 this.showToast('Process or clear existing selection first'); 221 return; 222 } 223 224 // Don't show if no video is loaded 225 const video = e.currentTarget.querySelector('video, canvas.video-player'); 226 if (!video || !video.src || video.readyState < 2) { 227 return; 228 } 229 230 // Don't show for hidden cameras (hidden by layout manager or CSS) 231 const container = e.currentTarget; 232 if (container.classList.contains('camera-hidden')) { 233 return; 234 } 235 const computedStyle = window.getComputedStyle(container); 236 if (computedStyle.display === 'none') { 237 return; 238 } 239 240 // Remove any existing context menu 241 this.removeContextMenu(); 242 243 // Get camera ID from the container 244 const cameraId = this.getCameraIdFromContainer(e.currentTarget); 245 246 // Create and show context menu 247 this.contextMenu = this.createContextMenu(e.clientX, e.clientY, cameraId); 248 document.body.appendChild(this.contextMenu); 249 250 // Listen for clicks outside to close 251 setTimeout(() => { 252 document.addEventListener('click', this.handleDocumentClick); 253 }, 0); 254 } 255 256 /** 257 * Get camera ID from video container 258 */ 259 getCameraIdFromContainer(container) { 260 const video = container.querySelector('video, canvas.video-player'); 261 if (!video) return null; 262 263 const id = video.id; 264 const mapping = { 265 'videoFront': 'front', 266 'videoBack': 'back', 267 'videoLeft': 'left_repeater', 268 'videoRight': 'right_repeater', 269 'videoLeftPillar': 'left_pillar', 270 'videoRightPillar': 'right_pillar' 271 }; 272 return mapping[id] || null; 273 } 274 275 /** 276 * Create context menu element 277 */ 278 createContextMenu(x, y, cameraId) { 279 const menu = document.createElement('div'); 280 menu.className = 'plate-enhancer-context-menu'; 281 // Build menu items conditionally â AI Search option a
281ppears only if 282 // the user has enabled AI Search for the current library. 283 const aiEnabled = !!window.aiSearch?.state?.enabled; 284 const currentEvent = window.app?.eventBrowser?.selectedEvent; 285 const aiHtml = (aiEnabled && currentEvent) 286 ? `<div class="plate-enhancer-context-menu-item" data-action="ai-deep-index"> 287 <svg viewBox="0 0 24 24"><path d="M13 3L4 14h7l-1 7 9-11h-7l1-7z"/></svg> 288 <span>Deep-index for AI Search</span> 289 </div>` 290 : ''; 291 menu.innerHTML = ` 292 <div class="plate-enhancer-context-menu-item" data-action="enhance"> 293 <svg viewBox="0 0 24 24"> 294 <path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/> 295 <path d="M12 10h-2v2H9v-2H7V9h2V7h1v2h2v1z"/> 296 </svg> 297 <span>Enhance Region...</span> 298 </div> 299 ${aiHtml} 300 `; 301 302 // Position menu 303 menu.style.left = `${x}px`; 304 menu.style.top = `${y}px`; 305 306 // Adjust if off-screen 307 setTimeout(() => { 308 const rect = menu.getBoundingClientRect(); 309 if (rect.right > window.innerWidth) { 310 menu.style.left = `${window.innerWidth - rect.width - 8}px`; 311 } 312 if (rect.bottom > window.innerHeight) { 313 menu.style.top = `${window.innerHeight - rect.height - 8}px`; 314 } 315 }, 0); 316 317 // Handle menu item click 318 menu.querySelector('[data-action="enhance"]').addEventListener('click', () => { 319 this.removeContextMenu(); 320 this.startSelection(cameraId); 321 }); 322 // Wire AI deep-index if present 323 const aiItem = menu.querySelector('[data-action="ai-deep-index"]'); 324 if (aiItem) { 325 aiItem.addEventListener('click', async () => { 326 this.removeContextMenu(); 327 const ev = window.app?.eventBrowser?.selectedEvent; 328 if (!ev || !window.aiSearchUI) return; 329 await window.aiSearchUI.runDeepIndex(ev.name, null); 330 }); 331 } 332 333 return menu; 334 } 335 336 /** 337 * Remove context menu 338 */ 339 removeContextMenu() { 340 if (this.contextMenu) { 341 this.contextMenu.remove(); 342 this.contextMenu = null; 343 } 344 document.removeEventListener('click', this.handleDocumentClick); 345 } 346 347 /** 348 * Handle clicks outside context menu 349 */ 350 handleDocumentClick(e) { 351 if (this.contextMenu && !this.contextMenu.contains(e.target)) { 352 this.removeContextMenu(); 353 } 354 } 355 356 /** 357 * Start region selection mode 358 */ 359 async startSelection(cameraId) { 360 // Pause video if playing 361 const videoPlayer = window.app?.videoPlayer; 362 if (videoPlayer && videoPlayer.isPlaying) { 363 await videoPlayer.pause(); 364 } 365 366 // Load OpenCV if needed 367 if (!this.isOpenCVLoaded) { 368 const loaded = await this.loadOpenCV(); 369 if (!loaded) { 370 this.showError('Failed to load image processing library. Please check your internet connection.'); 371 return; 372 } 373 } 374 375 this.isSelecting = true; 376 this.activeCamera = cameraId; 377 this.selections.clear(); 378 379 // Highlight button to show mode is active 380 this.setButtonActive(true); 381 382 // Add selection overlay to all video containers 383 this.createSelectionOverlays(); 384 385 // Listen for keyboard 386 document.addEventListener('keydown', this.handleKeyDown); 387 } 388 389 /** 390 * Create selection overlays on all visible video containers 391 */ 392 createSelectionOverlays() { 393 const videoContainers = document.querySelectorAll('.video-container'); 394 395 videoContainers.forEach(container => { 396 const video = container.querySelector('video, canvas.video-player'); 397 if (!video || !video.src) return; 398 399 // Skip cameras without loaded video (e.g., pillar cameras when event doesn't have them) 400 // readyState < 2 means video metadata not loaded yet 401 if (video.readyState < 2 || video.videoWidth === 0) return; 402 403 // Skip hidden cameras (hidden by layout manager class) 404 if (container.classList.contains('camera-hidden')) return; 405 406 // Check computed display style (catches CSS display: none from layout rules) 407 const computedStyle = window.getComputedStyle(container); 408 if (computedStyle.display === 'none') return; 409 410 // Also check if container is actually visible (has dimensions) 411 const rect = container.getBoundingClientRect(); 412 if (rect.width === 0 || rect.height === 0) return; 413 414 const cameraId = this.getCameraIdFromContainer(container); 415 if (!cameraId) return; 416
417 const overlay = document.createElement('div'); 418 overlay.className = 'plate-enhancer-overlay'; 419 overlay.dataset.camera = cameraId; 420 421 // Add instruction panel on first (active) camera - append to body to avoid overflow clipping 422 if (cameraId === this.activeCamera) { 423 const instruction = document.createElement('div'); 424 instruction.className = 'plate-enhancer-instruction'; 425 instruction.innerHTML = ` 426 <div class="instruction-icon">ð¯</div> 427 <div class="instruction-title">Select License Plate Region</div> 428 <div class="instruction-steps"> 429 <div class="instruction-step"><span class="step-num">1</span> Click on the license plate to place selection box</div> 430 <div class="instruction-step"><span class="step-num">2</span> Drag corners/edges to resize the selection</div> 431 <div class="instruction-step"><span class="step-num">3</span> Mark same plate on other camera angles (optional)</div> 432 </div> 433 <div class="instruction-auto-detect"> 434 <button class="auto-detect-btn" id="autoDetectPlatesBtn"> 435 <svg viewBox="0 0 24 24" width="16" height="16"> 436 <path fill="currentColor" d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.516 6.516 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5Z"/> 437 </svg> 438 Auto-Detect Plates 439 </button> 440 <span class="auto-detect-hint">AI finds plates automatically</span> 441 </div> 442 <div class="instruction-keys"> 443 <span><kbd>Enter</kbd> to continue</span> 444 <span class="key-separator">â¢</span> 445 <span><kbd>Esc</kbd> to cancel</span> 446 </div> 447 `; 448 449 // Bind auto-detect button click 450 setTimeout(() => { 451 const btn = document.getElementById('autoDetectPlatesBtn'); 452 if (btn) { 453 btn.addEventListener('click', () => this.autoDetectPlates()); 454 } 455 }, 0); 456 // Append to sidebar for proper positioning within sidebar bounds 457 const sidebar = document.querySelector('.sidebar'); 458 if (sidebar) { 459 sidebar.appendChild(instruction); 460 } else { 461 document.body.appendChild(instruction); 462 } 463 this.instructionElement = instruction; 464 } 465 466 // Handle click to create selection 467 overlay.addEventListener('click', (e) => this.handleOverlayClick(e, cameraId, container)); 468 469 // Note: Don't set container.style.position - the CSS already handles this 470 // Base CSS has position: relative, and some layouts use position: absolute 471 container.appendChild(overlay); 472 this.overlays.set(cameraId, overlay); 473 }); 474 } 475 476 /** 477 * Handle click on overlay to create/update selection 478 */ 479 handleOverlayClick(e, cameraId, container) { 480 // Ignore if we just finished dragging/resizing 481 if (this.isDraggingOrResizing) { 482 return; 483 } 484 485 const overlay = this.overlays.get(cameraId); 486 if (!overlay) return; 487 488 const rect = container.getBoundingClientRect(); 489 const video = container.querySelector('video, canvas.video-player'); 490 491 // Calculate where the video actually displays within the container 492 // (accounting for object-fit: contain letterboxing) 493 const videoDisplay = this.getVideoDisplayRect(video, rect); 494 495 // Calculate click position relative to container 496 const clickX = e.clientX - rect.left; 497 const clickY = e.clientY - rect.top; 498 499 // Default selection size (will be resizable) 500 const defaultWidth = 120; 501 const defaultHeight = 50; 502 503 // Center selection on click 504 const selection = { 505 x: Math.max(0, clickX - defaultWidth / 2), 506 y: Math.max(0, clickY - defaultHeight / 2), 507 width: defaultWidth, 508 height: defaultHeight, 509 containerWidth: rect.width, 510 containerHeight: rect.height, 511 videoWidth: video.videoWidth, 512 videoHeight: video.videoHeight, 513 // Store video display info for accurate coordinate mapping 514 videoDisplayX: videoDisplay.x, 515 videoDisplayY: videoDisplay.y, 516 videoDisplayWidth: videoDisplay.width, 517 videoDisplayHeight: videoDisplay.height 518 }; 519 520 // Constrain to container 521 if (selection.x + selection.width > rect.width) { 522 selection.x = rect.width - selection.width; 523 } 524 if (selection.y + selection.height > rect.height) { 525 selection.y = rect.height - selection.height; 526 } 527 528 console.log(`[PlateEnhancer] Selection created: click=(${clickX.toFixed(0)},${clickY.toFixed(0)}) box=(${selection.x.toFixed(0)},${selection.y.toFixed(0)} ${selection.width}x${selection.height}) container=${rect.width.toFixed(0)}x${rect.height.toFixed(0)} videoDisplay=(${videoDisplay.x.toFixed(0)},${videoDisplay.y.toFixed(0)} ${videoDisplay.width.toFixed(0)}x${videoDisplay.height.toFixed(0)}) video=${video.videoWidth}x${video.videoHeight}`); 529 this.selections.set(cameraId, selection); 530 this.renderSelection(cameraId, overlay, selection); 531 } 532 533 /** 534 * Calculate where the video actually displays within its container 535 * Accounts for object-fit: contain letterboxing 536 */ 537 getVideoDisplayRect(video, containerRect) { 538 const videoAspect = video.videoWidth / video.videoHeight; 539 const containerAspect = containerRect.width / containerRect.height; 540 541 let displayWidth, displayHeight, displayX, displayY; 542 543 if (videoAspect > containerAspect) { 544 // Video is wider - letterbox top/bottom 545 displayWidth = containerRect.width; 546 displayHeight = containerRect.width / videoAspect; 547 displayX = 0; 548 displayY = (containerRect.height - displayHeight) / 2; 549 } else { 550 // Video is taller - letterbox left/right 551 displayHeight = containerRect.height; 552 displayWidth = containerRect.height * videoAspect; 553 displayX = (containerRect.width - displayWidth) / 2; 554 displayY = 0; 555 } 556 557 return { 558 x: displayX, 559 y: displayY, 560 width: displayWidth, 561 height: displayHeight 562 }; 563 } 564 565 /** 566 * Auto-detect license plates using AI and enter cycling mode 567 * Scans ALL cameras and collects all detected plates 568 */ 569 async autoDetectPlates() { 570 const btn = document.getElementById('autoDetectPlatesBtn'); 571 if (btn) { 572 btn.disabled = true; 573 btn.innerHTML = ` 574 <svg class="spinner" viewBox="0 0 24 24" width="16" height="16"> 575 <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" stroke-dasharray="31.4 31.4" transform="rotate(-90 12 12)"/> 576 </svg> 577 Detecting... 578 `; 579 } 580 581 try { 582 // Guard: Don't start detection if cycling mode is active 583 if (this.cyclingState.isActive) { 584 console.log('[PlateEnhancer] Already in cycling mode'); 585 return; 586 } 587 588 // Guard: Don't start if already processing 589 if (this.isProcessing) { 590 this.showToast('Enhancement in progress - please wait', 'info'); 591 return; 592 } 593 594 // Ensure overlays exist (needed when called directly via 'D' key) 595 // Also set proper state when entering via 'D' key 596 if (!this.overlays || this.overlays.size === 0) { 597 // Set active camera before creating overlays (overlays check this) 598 if (!this.activeCamera) { 599 this.activeCamera = 'front'; 600 } 601 this.isSelecting = true; 602 this.selections.clear(); 603 this.createSelectionOverlays(); 604 // Add keyboard listener for Escape to cancel 605 document.addEventListener('keydown', this.handleKeyDown); 606 // Highlight button to show mode is active 607 this.setButtonActive(true); 608 } 609 610 // Initialize plate detector if not available 611 if (!window.plateDetector) { 612 window.plateDetector = new PlateDetector(); 613 } 614 615 const detector = window.plateDetector; 616 617 // Load model if needed 618 if (!detector.isReady()) { 619 console.log('[PlateEnhancer] Loading plate detection model...'); 620 const loaded = await detector.loadModel((progress) => { 621 if (btn && progress.percent >= 0) { 622 btn.innerHTML = ` 623 <svg class="spinner" viewBox="0 0 24 24" width="16" height="16"> 624 <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" stroke-dasharray="31.4 31.4" transform="rotate(-90 12 12)"/> 625 </svg> 626 Loading AI... ${progress.percent}% 627 `; 628 } 629 }); 630 631 if (!loaded) { 632 this.showToast('Failed to load plate detection model', 'error'); 633 return; 634 } 635 } 636 637 // Run detection on ALL visible cameras and collect ALL detections 638 const allDetections = []; 639 640 for (const [cameraId, overlay] of this.overlays) { 641 const video = this.getVideoElement(cameraId); 642 if (!video || video.readyState < 2) continue; 643 644 // Run detection 645 const detections = await detector.detect(video); 646 console.log(`[PlateEnhancer] ${cameraId}: Found ${detections.length} plates`); 647 648 // Store each detection with camera info 649 for (const detection of detections) { 650 const container = overlay.parentElement; 651 const rect = container.getBoundingClientRect(); 652 const videoDisplay = this.getVideoDisplayRect(video, rect); 653 654 allDetections.push({ 655 cameraId, 656 bbox: { 657 x: detection.x, 658 y: detection.y, 659 width: detection.width, 660 height: detection.height 661 }, 662 confidence: detection.confidence, 663 videoDims: { 664 width: video.videoWidth, 665 height: video.videoHeight 666 }, 667 containerDims: { 668 width: rect.width, 669 height: rect.height 670 }, 671 videoDisplay: { 672 x: videoDisplay.x, 673 y: videoDisplay.y, 674 width: videoDisplay.width, 675 height: videoDisplay.height 676 } 677 }); 678 } 679 } 680 681 // Sort by confidence (highest first) 682 allDetections.sort((a, b) => b.confidence - a.confidence); 683 684 if (allDetections.length === 0) { 685 // No plates detected - offer manual selection 686 this.showToast('No plates auto-detected. Click and drag to select a regi
686on manually.', 'info'); 687 688 // If we're already in selection mode (overlays exist), just let user continue manually 689 // Don't create duplicate overlays or enter selection mode again 690 if (this.isSelecting && this.overlays && this.overlays.size > 0) { 691 console.log('[PlateEnhancer] Already in selection mode, user can select manually'); 692 return; 693 } 694 695 // If called via 'D' key without being in selection mode, enter selection mode properly 696 // Get focused camera or default to front 697 const vp = window.app?.videoPlayer; 698 let focusedCameraId = null; 699 700 // Try to get focused video's camera ID 701 const focusedVideo = vp?.getFocusedVideo?.(); 702 if (focusedVideo) { 703 focusedCameraId = this.getCameraIdFromContainer(focusedVideo.parentElement); 704 } 705 706 // Default to front camera if no focus 707 if (!focusedCameraId) { 708 focusedCameraId = 'front'; 709 } 710 711 // Clean up any partial state before entering selection mode 712 this.removeOverlays(); 713 this.selections.clear(); 714 this.isSelecting = false; 715 this.activeCamera = null; 716 document.removeEventListener('keydown', this.handleKeyDown); 717 718 // Enter proper selection mode 719 await this.startSelection(focusedCameraId); 720 return; 721 } 722 723 // Remove instruction panel and overlays, reset selection state 724 if (this.instructionElement) { 725 this.instructionElement.remove(); 726 this.instructionElement = null; 727 } 728 this.removeOverlays(); 729 this.isSelecting = false; 730 this.activeCamera = null; 731 document.removeEventListener('keydown', this.handleKeyDown); 732 733 // Enter cycling mode - capture time NOW when plate was detected 734 const vp = window.app?.videoPlayer; 735 const detectionTime = vp?.getCurrentAbsoluteTime?.() ?? vp?.getCurrentTime?.() ?? 0; 736 737 this.cyclingState = { 738 isActive: true, 739 detections: allDetections, 740 currentIndex: 0, 741 adjustedBbox: null, 742 detectionTime: detectionTime // Time when plate was detected 743 }; 744 745 this.showPlateCyclingUI(); 746 this.showToast(`Found ${allDetections.length} plate${allDetections.length > 1 ? 's' : ''} - use â â to cycle`, 'success'); 747 748 } catch (error) { 749 console.error('[PlateEnhancer] Auto-detect error:', error); 750 this.showToast('Detection failed: ' + error.message, 'error'); 751 } finally { 752 // Reset button if still visible 753 if (btn) { 754 btn.disabled = false; 755 btn.innerHTML = ` 756 <svg viewBox="0 0 24 24" width="16" height="16"> 757 <path fill="currentColor" d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.516 6.516 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5Z"/> 758 </svg> 759 Auto-Detect Plates 760 `; 761 } 762 } 763 } 764 765 /** 766 * Show the plate cycling UI overlay 767 * Displays one plate at a time with navigation, adjustable bbox, and timeframe selection 768 */ 769 showPlateCyclingUI() { 770 const { detections, currentIndex } = this.cyclingState; 771 if (detections.length === 0) return; 772 773 // Pause video to freeze the frame for accurate preview 774 const videoPlayer = window.app?.videoPlayer; 775 if (videoPlayer && videoPlayer.isPlaying) { 776 videoPlayer.pause(); 777 } 778 779 // Remove existing overlay 780 if (this.cyclingOverlay) { 781 this.cyclingOverlay.remove(); 782 } 783 784 const detection = detections[currentIndex]; 785 const video = this.getVideoElement(detection.cameraId); 786 const container = video?.parentElement; 787 if (!container) return; 788 789 // Get available footage time 790 const vp = window.app?.videoPlayer; 791 const currentTime = vp?.getCurrentAbsoluteTime?.() ?? vp?.getCurrentTime?.() ?? 0; 792 // Use cached duration for synchronous UI (getTotalDuration is async) 793 let totalDuration = 600; // Default fallback 794 if (vp?.cachedClipDurations?.length > 0) { 795 const cached = vp.cachedClipDurations.reduce((a, b) => a + b, 0); 796 if (cached > 0) totalDuration = cached; 797 } 798 const maxBefore = Math.min(currentTime, 10); 799 const maxAfter = Math.min(totalDuration - currentTime, 10); 800 801 // Create the cycling overlay 802 this.cyclingOverlay = document.createElement('div'); 803 this.cyclingOverlay.className = 'plate-cycling-overlay'; 804 this.cyclingOverlay.innerHTML = ` 805 <div class="plate-cycling-panel"> 806 <div class="plate-cycling-header"> 807 ${detections.length > 1 ? ` 808 <button class="plate-cycling-nav-btn prev-btn" ${currentIndex === 0 ? 'disabled' : ''} title="Previous plate (â)"> 809 <svg viewBox="0 0 24 24" width="20" height="20">
809<path fill="currentColor" d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg> 810 </button>` : ''} 811 <div class="plate-cycling-info"> 812 <span class="plate-cycling-counter">${detection.isManual ? 'Manual Selection' : `Plate ${currentIndex + 1} of ${detections.length}`}</span> 813 <span class="plate-cycling-camera">${this.getCameraDisplayName(detection.cameraId)}</span> 814 <span class="plate-cycling-confidence">${detection.isManual ? '' : `Confidence: ${Math.round(detection.confidence * 100)}%`}</span> 815 </div> 816 ${detections.length > 1 ? ` 817 <button class="plate-cycling-nav-btn next-btn" ${currentIndex === detections.length - 1 ? 'disabled' : ''} title="Next plate (â)"> 818 <svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z"/></svg> 819 </button>` : ''} 820 </div> 821 822 <div class="plate-cycling-video-container" data-camera="${detection.cameraId}"> 823 <!-- Video preview with bounding box will be inserted here --> 824 </div> 825 826 <div class="plate-cycling-timeframe"> 827 <div class="plate-cycling-timeframe-label">Analysis Timeframe:</div> 828 <div class="plate-cycling-timeframe-btns"> 829 <button class="plate-cycling-timeframe-btn ${this.preferredTimeframe === 2 ? 'selected' : ''}" data-seconds="2" ${maxBefore < 1 && maxAfter < 1 ? 'disabled' : ''}> 830 ±2s 831 <span class="timeframe-hint">Quick</span> 832 </button> 833 <button class="plate-cycling-timeframe-btn ${this.preferredTimeframe === 5 ? 'selected' : ''}" data-seconds="5" ${maxBefore < 2.5 && maxAfter < 2.5 ? 'disabled' : ''}> 834 ±5s 835 <span class="timeframe-hint">Recommended</span> 836 </button> 837 <button class="plate-cycling-timeframe-btn ${this.preferredTimeframe === 10 ? 'selected' : ''}" data-seconds="10" ${maxBefore < 5 && maxAfter < 5 ? 'disabled' : ''}> 838 ±10s 839 <span class="timeframe-hint">Thorough</span> 840 </button> 841 </div> 842 <div class="plate-cycling-available">Available: ${maxBefore.toFixed(1)}s before, ${maxAfter.toFixed(1)}s after</div> 843 </div> 844 845 <div class="plate-cycling-option"> 846 <label class="plate-cycling-checkbox"> 847 <input type="checkbox" id="apply-video-enhancements" ${this.applyVideoEnhancementsToSource ? 'checked' : ''}> 848 <span class="checkmark"></span> 849 Apply Video Enhancements to source frames 850 </label> 851 <span class="plate-cycling-option-hint">Use your brightness/contrast/saturation settings</span> 852 </div> 853 854 <div class="plate-cycling-size-warning" style="display: none;"> 855 <!-- Size warning will be inserted here dynamically --> 856 </div> 857 858 <div class="plate-cycling-actions"> 859 <button class="plate-cycling-btn cancel-btn">Cancel</button> 860 <button class="plate-cycling-btn enhance-btn primary"> 861 <svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z M12 10h-2v2H9v-2H7V9h2V7h1v2h2v1z"/></svg> 862 Enhance This Plate 863 </button> 864 </div> 865 866 <div class="plate-cycling-shortcuts"> 867 ${detections.length > 1 ? '<kbd>â</kbd> <kbd>â</kbd> Navigate ⢠' : ''}<kbd>Enter</kbd> Select ⢠<kbd>Esc</kbd> Cancel 868 </div> 869 </div> 870 `; 871 872 document.body.appendChild(this.cyclingOverlay); 873 874 // Render the video preview with bounding box 875 this.renderCyclingPreview(detection); 876 877 // Bind event handlers 878 this.bindCyclingEvents(); 879 880 // Add keyboard listener 881 document.addEventListener('keydown', this.handleCyclingKeyDown); 882 } 883 884 /**
885 * Render the video preview with adjustable bounding box in cycling mode 886 */ 887 renderCyclingPreview(detection) { 888 const previewContainer = this.cyclingOverlay.querySelector('.plate-cycling-video-container'); 889 if (!previewContainer) return; 890 891 const video = this.getVideoElement(detection.cameraId); 892 if (!video || video.readyState < 2) return; 893 894 // Create a canvas to show the video frame with bounding box 895 const canvas = document.createElement('canvas'); 896 const previewWidth = 600; 897 const previewHeight = Math.round(previewWidth * (video.videoHeight / video.videoWidth)); 898 canvas.width = previewWidth; 899 canvas.height = previewHeight; 900 canvas.className = 'plate-cycling-canvas'; 901 902 const ctx = canvas.getContext('2d'); 903 904 // Apply video enhancements to preview if enabled 905 if (this.applyVideoEnhancementsToSource) { 906 const settings = this.getVideoEnhancementSettings(); 907 this.applyVideoEnhancementsToCanvas(ctx, settings); 908 } 909 910 ctx.drawImage(video, 0, 0, previewWidth, previewHeight); 911 912 // Reset filter after drawing 913 ctx.filter = 'none'; 914 915 // Calculate scaled bbox position 916 const scaleX = previewWidth / video.videoWidth; 917 const scaleY = previewHeight / video.videoHeight; 918 919 // Use adjusted bbox if available, otherwise use detection bbox 920 const bbox = this.cyclingState.adjustedBbox || detection.bbox; 921 const padding = this.bboxPadding; 922 923 const x = (bbox.x - bbox.width * padding) * scaleX; 924 const y = (bbox.y - bbox.height * padding) * scaleY; 925 const w = bbox.width * (1 + padding * 2) * scaleX; 926 const h = bbox.height * (1 + padding * 2) * scaleY; 927 928 previewContainer.innerHTML = ''; 929 930 // Create wrapper to ensure selection box is positioned relative to canvas 931 const canvasWrapper = document.createElement('div'); 932 canvasWrapper.className = 'plate-cycling-canvas-wrapper'; 933 canvasWrapper.style.position = 'relative'; 934 canvasWrapper.style.display = 'inline-block'; 935 canvasWrapper.appendChild(canvas); 936 937 // Add selection box overlay using percentages (so it scales with CSS-scaled canvas) 938 const selectionBox = document.createElement('div'); 939 selectionBox.className = 'plate-cycling-selection'; 940 // Use percentage positioning relative to canvas size 941 const leftPct = (x / previewWidth) * 100; 942 const topPct = (y / previewHeight) * 100; 943 const widthPct = (w / previewWidth) * 100; 944 const heightPct = (h / previewHeight) * 100; 945 selectionBox.style.left = `${leftPct}%`; 946 selectionBox.style.top = `${topPct}%`; 947 selectionBox.style.width = `${widthPct}%`; 948 selectionBox.style.height = `${heightPct}%`; 949 950 // Add resize handles 951 ['nw', 'ne', 'sw', 'se'].forEach(pos => { 952 const handle = document.createElement('div'); 953 handle.className = `plate-cycling-handle ${pos}`; 954 handle.addEventListener('mousedown', (e) => this.startCyclingResize(e, pos, detection, scaleX, scaleY)); 955 selectionBox.appendChild(handle); 956 }); 957 958 // Add size indicator showing video pixel dimensions 959 const sizeIndicator = document.createElement('div'); 960 sizeIndicator.className = 'plate-enhancer-size-indicator'; 961 selectionBox.appendChild(sizeIndicator); 962 this.updateCyclingSizeIndicator(detection, sizeIndicator); 963 964 // Make box draggable 965 selectionBox.addEventListener('mousedown', (e) => { 966 if (e.target === selectionBox) { 967 this.startCyclingDrag(e, detection, scaleX, scaleY); 968 } 969 }); 970 971 // Add selection box to the canvas wrapper (so it's positioned relative to canvas) 972 canvasWrapper.appendChild(selectionBox); 973 previewContainer.appendChild(canvasWrapper); 974 975 // Store canvas and scale for resize/drag handlers 976 this.cyclingCanvas = { canvas, scaleX, scaleY, previewWidth, previewHeight, canvasWrapper }; 977 978 // Update size warning if selection is large 979 this.updateCyclingSizeWarning(detection); 980 } 981 982 /** 983 * Update the size warning in cycling mode based on current bbox 984 */ 985 updateCyclingSizeWarning(detection) { 986 const warningContainer = this.cyclingOverlay?.querySelector('.plate-cycling-size-warning'); 987 if (!warningContainer) return; 988 989 const bbox = this.cyclingState.adjustedBbox || detection.bbox; 990 const area = Math.round(bbox.width * bbox.height); 991 992 // Same thresholds as regular selection 993 const isLarge = area > 40000; 994 const isVeryLarge = area > 100000; 995 996 if (isVeryLarge) { 997 warningContainer.style.display = 'block'; 998 warningContainer.innerHTML = ` 999 <div class="plate-enhancer-warning plate-enhancer-warning-severe"> 1000 ð <strong>Very large selection</strong> (${Math.round(bbox.width)}Ã${Math.round(bbox.height)}px = ${Math.round(area/1000)}k pixels).<br> 1001 Processing will be significantly slower. Consider resizing to select just the license plate area for faster results. 1002 </div> 1003 `; 1004 } else if (isLarge) { 1005 warningContainer.style.display = 'block'; 1006 warningContainer.innerHTML = ` 1007 <div class="plate-enhancer-warning"> 1008 ð Large selection (${Math.round(bbox.width)}Ã${Math.round(bbox.height)}px). Processing may take longer than usual. 1009 </div> 1010 `; 1011 } else { 1012 warningContainer.style.display = 'none'; 1013 warningContainer.innerHTML = ''; 1014 } 1015 } 1016 1017 /** 1018 * Start dragging the selection box in cycling mode 1019 */ 1020 startCyclingDrag(e, detection, scaleX, scaleY) { 1021 e.preventDefault(); 1022 e.stopPropagation(); 1023 1024 const selectionBox = e.target; 1025 const startX = e.clientX; 1026 const startY = e.clientY; 1027 // Values are now percentages (0-100) 1028 const origLeftPct = parseFloat(selectionBox.style.left); 1029 const origTopPct = parseFloat(selectionBox.style.top); 1030 const widthPct = parseFloat(selectionBox.style.width); 1031 const heightPct = parseFloat(selectionBox.style.height); 1032 1033 const { previewWidth, previewHeight, canvas } = this.cyclingCanvas; 1034 1035 // Get the actual displayed size of the canvas (may be CSS-scaled) 1036 const canvasRect = canvas.getBoundingClientRect(); 1037 1038 const onMove = (moveEvent) => { 1039 // Convert mouse movement to percentage of canvas size 1040 const dxPct = ((moveEvent.clientX - startX) / canvasRect.width) * 100; 1041 const dyPct = ((moveEvent.clientY - startY) / canvasRect.height) * 100; 1042 1043 let newLeftPct = Math.max(0, Math.min(origLeftPct + dxPct, 100 - widthPct)); 1044 let newTopPct = Math.max(0, Math.min(origTopPct + dyPct, 100 - heightPct)); 1045 1046 selectionBox.style.left = `${newLeftPct}%`; 1047 selectionBox.style.top = `${newTopPct}%`; 1048 }; 1049 1050 const onUp = () => { 1051 document.removeEventListener('mousemove', onMove); 1052 document.removeEventListener('mouseup', onUp); 1053 1054 // Update adjusted bbox in video coordinates 1055 const padding = this.bboxPadding; 1056 // Convert from percentage to preview pixel coords, then to video coords 1057 const leftPct = parseFloat(selectionBox.style.left); 1058 const topPct = parseFloat(selectionBox.style.top); 1059 const wPct = parseFloat(selectionBox.style.width); 1060 const hPct = parseFloat(selectionBox.style.height); 1061 1062 // Convert percentages to preview pixels 1063 const displayLeft = (leftPct / 100) * previewWidth;
1064 const displayTop = (topPct / 100) * previewHeight; 1065 const displayW = (wPct / 100) * previewWidth; 1066 const displayH = (hPct / 100) * previewHeight; 1067 1068 // Convert from display (preview) coords to video coords 1069 const left = displayLeft / scaleX; 1070 const top = displayTop / scaleY; 1071 const w = displayW / scaleX / (1 + padding * 2); 1072 const h = displayH / scaleY / (1 + padding * 2); 1073 1074 this.cyclingState.adjustedBbox = { 1075 x: left + w * padding, 1076 y: top + h * padding, 1077 width: w, 1078 height: h 1079 }; 1080 1081 console.log(`[PlateEnhancer] Drag end: pct=(${leftPct.toFixed(1)}%,${topPct.toFixed(1)}% ${wPct.toFixed(1)}%x${hPct.toFixed(1)}%) => display=(${displayLeft.toFixed(1)},${displayTop.toFixed(1)} ${displayW.toFixed(1)}x${displayH.toFixed(1)}) scale=(${scaleX.toFixed(4)},${scaleY.toFixed(4)}) => bbox=(${this.cyclingState.adjustedBbox.x.toFixed(1)},${this.cyclingState.adjustedBbox.y.toFixed(1)} ${this.cyclingState.adjustedBbox.width.toFixed(1)}x${this.cyclingState.adjustedBbox.height.toFixed(1)})`); 1082 }; 1083 1084 document.addEventListener('mousemove', onMove); 1085 document.addEventListener('mouseup', onUp); 1086 } 1087 1088 /** 1089 * Start resizing the selection box in cycling mode 1090 */ 1091 startCyclingResize(e, handle, detection, scaleX, scaleY) { 1092 e.preventDefault(); 1093 e.stopPropagation(); 1094 1095 const selectionBox = e.target.parentElement; 1096 const startX = e.clientX; 1097 const startY = e.clientY; 1098 // Values are now percentages (0-100) 1099 const origLeftPct = parseFloat(selectionBox.style.left); 1100 const origTopPct = parseFloat(selectionBox.style.top); 1101 const origWidthPct = parseFloat(selectionBox.style.width); 1102 const origHeightPct = parseFloat(selectionBox.style.height); 1103 1104 const { previewWidth, previewHeight, canvas } = this.cyclingCanvas; 1105 const minSizePct = 3; // Minimum size as percentage (about 3% of canvas) 1106 const padding = this.bboxPadding; // Define here so it's accessible in both onMove and onUp 1107 1108 // Get the actual displayed size of the canvas (may be CSS-scaled) 1109 const canvasRect = canvas.getBoundingClientRect(); 1110 1111 const onMove = (moveEvent) => { 1112 // Convert mouse movement to percentage of canvas size 1113 const dxPct = ((moveEvent.clientX - startX) / canvasRect.width) * 100; 1114 const dyPct = ((moveEvent.clientY - startY) / canvasRect.height) * 100; 1115 1116 let newLeftPct = origLeftPct; 1117 let newTopPct = origTopPct; 1118 let newWidthPct = origWidthPct; 1119 let newHeightPct = origHeightPct; 1120 1121 if (handle.includes('e')) { 1122 newWidthPct = Math.max(minSizePct, origWidthPct + dxPct); 1123 } 1124 if (handle.includes('w')) { 1125 const proposedW = origWidthPct - dxPct; 1126 if (proposedW >= minSizePct) { 1127 newWidthPct = proposedW; 1128 newLeftPct = origLeftPct + dxPct; 1129 } 1130 } 1131 if (handle.includes('s')) { 1132 newHeightPct = Math.max(minSizePct, origHeightPct + dyPct); 1133 } 1134 if (handle.includes('n')) { 1135 const proposedH = origHeightPct - dyPct; 1136 if (proposedH >= minSizePct) { 1137 newHeightPct = proposedH; 1138 newTopPct = origTopPct + dyPct; 1139 } 1140 } 1141 1142 // Constrain to bounds (0-100%) 1143 newLeftPct = Math.max(0, newLeftPct); 1144 newTopPct = Math.max(0, newTopPct); 1145 if (newLeftPct + newWidthPct > 100) newWidthPct = 100 - newLeftPct; 1146 if (newTopPct + newHeightPct > 100) newHeightPct = 100 - newTopPct; 1147 1148 selectionBox.style.left = `${newLeftPct}%`; 1149 selectionBox.style.top = `${newTopPct}%`; 1150 selectionBox.style.width = `${newWidthPct}%`; 1151 selectionBox.style.height = `${newHeightPct}%`; 1152 1153 // Update size indicator during resize 1154 const liveDisplayW = (newWidthPct / 100) * previewWidth;
1155 const liveDisplayH = (newHeightPct / 100) * previewHeight; 1156 const liveW = liveDisplayW / scaleX / (1 + padding * 2); 1157 const liveH = liveDisplayH / scaleY / (1 + padding * 2); 1158 const sizeIndicator = selectionBox.querySelector('.plate-enhancer-size-indicator'); 1159 if (sizeIndicator) { 1160 const tempBbox = { width: liveW, height: liveH }; 1161 this.updateCyclingSizeIndicator({ bbox: tempBbox }, sizeIndicator); 1162 } 1163 }; 1164 1165 const onUp = () => { 1166 document.removeEventListener('mousemove', onMove); 1167 document.removeEventListener('mouseup', onUp); 1168 1169 // Update adjusted bbox in video coordinates (padding defined at outer scope) 1170 // Convert from percentage to preview pixel coords, then to video coords 1171 const leftPct = parseFloat(selectionBox.style.left); 1172 const topPct = parseFloat(selectionBox.style.top); 1173 const wPct = parseFloat(selectionBox.style.width); 1174 const hPct = parseFloat(selectionBox.style.height); 1175 1176 // Convert percentages to preview pixels 1177 const displayLeft = (leftPct / 100) * previewWidth; 1178 const displayTop = (topPct / 100) * previewHeight; 1179 const displayW = (wPct / 100) * previewWidth; 1180 const displayH = (hPct / 100) * previewHeight; 1181 1182 // Convert from display (preview) coords to video coords 1183 const left = displayLeft / scaleX;
1184 const top = displayTop / scaleY; 1185 const w = displayW / scaleX / (1 + padding * 2); 1186 const h = displayH / scaleY / (1 + padding * 2); 1187 1188 this.cyclingState.adjustedBbox = { 1189 x: left + w * padding, 1190 y: top + h * padding, 1191 width: w, 1192 height: h 1193 }; 1194 1195 // Update size indicator with final bbox 1196 const sizeIndicator = selectionBox.querySelector('.plate-enhancer-size-indicator'); 1197 if (sizeIndicator) { 1198 this.updateCyclingSizeIndicator({ bbox: this.cyclingState.adjustedBbox }, sizeIndicator); 1199 } 1200 1201 // Update the size warning in the panel 1202 this.updateCyclingSizeWarning({ bbox: this.cyclingState.adjustedBbox }); 1203 1204 console.log(`[PlateEnhancer] Resize end: pct=(${leftPct.toFixed(1)}%,${topPct.toFixed(1)}% ${wPct.toFixed(1)}%x${hPct.toFixed(1)}%) => display=(${displayLeft.toFixed(1)},${displayTop.toFixed(1)} ${displayW.toFixed(1)}x${displayH.toFixed(1)}) scale=(${scaleX.toFixed(4)},${scaleY.toFixed(4)}) => bbox=(${this.cyclingState.adjustedBbox.x.toFixed(1)},${this.cyclingState.adjustedBbox.y.toFixed(1)} ${this.cyclingState.adjustedBbox.width.toFixed(1)}x${this.cyclingState.adjustedBbox.height.toFixed(1)})`); 1205 }; 1206 1207 document.addEventListener('mousemove', onMove); 1208 document.addEventListener('mouseup', onUp); 1209 } 1210 1211 /** 1212 * Bind event handlers for cycling UI 1213 */ 1214 bindCyclingEvents() { 1215 if (!this.cyclingOverlay) return; 1216 1217 // Navigation buttons 1218 this.cyclingOverlay.querySelector('.prev-btn')?.addEventListener('click', () => this.cyclePlate('prev')); 1219 this.cyclingOverlay.querySelector('.next-btn')?.addEventListener('click', () => this.cyclePlate('next')); 1220 1221 // Timeframe buttons 1222 this.cyclingOverlay.querySelectorAll('.plate-cycling-timeframe-btn').forEach(btn => { 1223 btn.addEventListener('click', () => { 1224 if (btn.disabled) return; 1225 this.cyclingOverlay.querySelectorAll('.plate-cycling-timeframe-btn').forEach(b => b.classList.remove('selected')); 1226 btn.classList.add('selected'); 1227 this.preferredTimeframe = parseInt(btn.dataset.seconds); 1228 localStorage.setItem('tcv_plate_timeframe', this.preferredTimeframe.toString()); 1229 }); 1230 }); 1231 1232 // Cancel button 1233 this.cyclingOverlay.querySelector('.cancel-btn')?.addEventListener('click', () => this.exitCyclingMode()); 1234 1235 // Enhance button 1236 this.cyclingOverlay.querySelector('.enhance-btn')?.addEventListener('click', () => this.startEnhancementFromCycling()); 1237 1238 // Video enhancements checkbox - re-render preview when toggled 1239 const enhancementsCheckbox = this.cyclingOverlay.querySelector('#apply-video-enhancements'); 1240 if (enhancementsCheckbox) { 1241 enhancementsCheckbox.addEventListener('change', (e) => { 1242 this.applyVideoEnhancementsToSource = e.target.checked; 1243 localStorage.setItem('tcv_plate_apply_video_enhancements', this.applyVideoEnhancementsToSource.toString()); 1244 1245 // Re-render preview with/without video enhancements 1246 const detection = this.cyclingState.detections[this.cyclingState.currentIndex]; 1247 if (detection) { 1248 this.renderCyclingPreview(detection); 1249 } 1250 }); 1251 } 1252 } 1253 1254 /** 1255 * Handle keyboard events in cycling mode 1256 */ 1257 handleCyclingKeyDown(e) { 1258 if (!this.cyclingState.isActive) return; 1259 1260 if (e.key === 'ArrowLeft') { 1261 e.preventDefault(); 1262 this.cyclePlate('prev'); 1263 } else if (e.key === 'ArrowRight') { 1264 e.preventDefault(); 1265 this.cyclePlate('next'); 1266 } else if (e.key === 'Enter') { 1267 e.preventDefault(); 1268 this.startEnhancementFromCycling(); 1269 } else if (e.key === 'Escape') { 1270 e.preventDefault(); 1271 this.exitCyclingMode(); 1272 } 1273 } 1274 1275 /** 1276 * Update size indicator in cycling mode 1277 * @param {Object} detection - Detection object with bbox 1278 * @param {HTMLElement} indicator - The size indicator element 1279 */ 1280 updateCyclingSizeIndicator(detection, indicator) { 1281 // Get the current bbox (adjusted or original) 1282 const bbox = this.cyclingState.adjustedBbox || detection.bbox; 1283 const area = Math.round(bbox.width * bbox.height); 1284 1285 // Same thresholds as regular selection 1286 const isLarge = area > 40000; 1287 const isVeryLarge = area > 100000; 1288 1289 indicator.textContent = `${Math.round(bbox.width)}Ã${Math.round(bbox.height)}px`; 1290 indicator.classList.toggle('warning', isLarge && !isVeryLarge); 1291 indicator.classList.toggle('critical', isVeryLarge); 1292 1293 if (isVeryLarge) { 1294 indicator.title = 'Very large selection - processing will be slow. Try selecting just the plate area.'; 1295 } else if (isLarge) { 1296 indicator.title = 'Large selection - processing may be slower than usual.'; 1297 } else { 1298 indicator.title = 'Selection size in video pixels'; 1299 } 1300 } 1301 1302 /** 1303 * Cycle to previous or next plate 1304 */ 1305 cyclePlate(direction) { 1306 const { detections, currentIndex } = this.cyclingState; 1307 1308 let newIndex = currentIndex; 1309 if (direction === 'prev' && currentIndex > 0) { 1310 newIndex = currentIndex - 1; 1311 } else if (direction === 'next' && currentIndex < detections.length - 1) { 1312 newIndex = currentIndex + 1; 1313 } 1314 1315 if (newIndex !== currentIndex) { 1316 this.cyclingState.currentIndex = newIndex; 1317 this.cyclingState.adjustedBbox = null; // Reset adjustments for new plate 1318 this.showPlateCyclingUI(); 1319 } 1320 } 1321 1322 /** 1323 * Exit cycling mode and clean up 1324 */ 1325 exitCyclingMode() { 1326 this.cyclingState.isActive = false;
1327 this.cyclingState.detections = []; 1328 this.cyclingState.currentIndex = 0; 1329 this.cyclingState.adjustedBbox = null; 1330 1331 // Clear any manual selections that led to this cycling UI 1332 this.selections.clear(); 1333 1334 if (this.cyclingOverlay) { 1335 this.cyclingOverlay.remove(); 1336 this.cyclingOverlay = null; 1337 } 1338 1339 document.removeEventListener('keydown', this.handleCyclingKeyDown); 1340 // Remove button highlight 1341 this.setButtonActive(false); 1342 } 1343 1344 /** 1345 * Start enhancement from cycling mode with selected plate and timeframe 1346 */ 1347 async startEnhancementFromCycling() { 1348 const { detections, currentIndex, adjustedBbox, detectionTime } = this.cyclingState; 1349 const detection = detections[currentIndex]; 1350 1351 // Get the selected timeframe 1352 const selectedBtn = this.cyclingOverlay?.querySelector('.plate-cycling-timeframe-btn.selected'); 1353 const timeframeSeconds = selectedBtn ? parseInt(selectedBtn.dataset.seconds) : this.preferredTimeframe; 1354 1355 // Use the time when plate was DETECTED, not current time (video may have moved) 1356 const vp = window.app?.videoPlayer; 1357 // Get total duration with robust fallback (getTotalDuration is async!) 1358 let totalDuration = 600; // Default fallback 1359 if (vp?.getTotalDuration) { 1360 try { 1361 const duration = await vp.getTotalDuration(); 1362 if (duration && Number.isFinite(duration) && duration > 0) { 1363 totalDuration = duration; 1364 } 1365 } catch (e) { 1366 console.warn('[PlateEnhancer] Failed to get total duration:', e); 1367 } 1368 } 1369 // Use saved detection time - this is when the plate was actually visible 1370 const currentTime = detectionTime ?? 0; 1371 this.referenceTime = currentTime; 1372 console.log(`[PlateEnhancer] Using detection time: ${currentTime.toFixed(2)}s (timeframe: ±${timeframeSeconds/2}s, duration: ${totalDuration.toFixed(1)}s)`); 1373 1374 // Close cycling UI 1375 this.exitCyclingMode(); 1376 1377 const halfRange = timeframeSeconds / 2; 1378 const startTime = Math.max(0, currentTime - halfRange); 1379 // Clamp to video end with 0.1s buffer to avoid seek issues at tail 1380 // But ensure endTime is never less than startTime 1381 let endTime = Math.min(currentTime + halfRange, totalDuration - 0.1); 1382 if (endTime < startTime) { 1383 console.warn(`[PlateEnhancer] endTime (${endTime.toFixed(2)}) < startTime (${startTime.toFixed(2)}), adjusting`); 1384 endTime = Math.min(currentTime, totalDuration - 0.1); 1385 } 1386 // Ensure we have at least some range to sample 1387 if (endTime <= startTime) { 1388 console.warn(`[PlateEnhancer] No valid range, using single frame at ${currentTime.toFixed(2)}s`); 1389 endTime = startTime + 0.1; // At least 0.1s range 1390 } 1391 console.log(`[PlateEnhancer] Time range: ${startTime.toFixed(2)}s - ${endTime.toFixed(2)}s (${(endTime - startTime).toFixed(2)}s range, ref: ${currentTime.toFixed(2)}s)`); 1392 1393 1394 // Build selection from the detection 1395 const video = this.getVideoElement(detection.cameraId); 1396 if (!video) { 1397 this.showError('Video not available'); 1398 return; 1399 } 1400 1401 const container = video.parentElement; 1402 const rect = container.getBoundingClientRect(); 1403 const videoDisplay = this.getVideoDisplayRect(video, rect); 1404 1405 // Use adjusted bbox if available 1406 const bbox = adjustedBbox || detection.bbox; 1407 const padding = this.bboxPadding; 1408 1409 const scaleX = videoDisplay.width / video.videoWidth; 1410 const scaleY = videoDisplay.height / video.videoHeight; 1411 1412 const paddedX = bbox.x - bbox.width * padding; 1413 const paddedY = bbox.y - bbox.height * padding; 1414 const paddedW = bbox.width * (1 + padding * 2); 1415 const paddedH = bbox.height * (1 + padding * 2); 1416 1417 const selection = { 1418 x: videoDisplay.x + paddedX * scaleX, 1419 y: videoDisplay.y + paddedY * scaleY, 1420 width: paddedW * scaleX, 1421 height: paddedH * scaleY, 1422 containerWidth: rect.width, 1423 containerHeight: rect.height, 1424 videoWidth: video.videoWidth, 1425 videoHeight: video.videoHeight, 1426 videoDisplayX: videoDisplay.x, 1427 videoDisplayY: videoDisplay.y, 1428 videoDisplayWidth: videoDisplay.width, 1429 videoDisplayHeight: videoDisplay.height, 1430 confidence: detection.confidence, 1431 autoDetected: true 1432 }; 1433 1434 // Set the selection and start processing 1435 this.selections.clear(); 1436 this.selections.set(detection.cameraId, selection); 1437 1438 // Use 4x super-resolution upscaling for best OCR results 1439 await this.startProcessing(startTime, endTime, true, 4); 1440 } 1441 1442 /** 1443 * Convert selection coordinates from container space to video pixel space 1444 * Properly handles letterboxing from object-fit: contain 1445 */ 1446 containerToVideoCoords(selection) { 1447 // Get the video display area within the container 1448 const displayX = selection.videoDisplayX || 0; 1449 const displayY = selection.videoDisplayY || 0; 1450 const displayWidth = selection.videoDisplayWidth || selection.containerWidth; 1451 const displayHeight = selection.videoDisplayHeight || selection.containerHeight; 1452 1453 // Convert from container coords to video display coords (remove letterbox offset) 1454 const relativeX = selection.x - displayX;
1455 const relativeY = selection.y - displayY; 1456 1457 // Scale from display size to actual video size 1458 const scaleX = selection.videoWidth / displayWidth; 1459 const scaleY = selection.videoHeight / displayHeight; 1460 1461 const videoX = Math.round(relativeX * scaleX); 1462 const videoY = Math.round(relativeY * scaleY); 1463 const videoW = Math.round(selection.width * scaleX); 1464 const videoH = Math.round(selection.height * scaleY); 1465 1466 // Debug logging for coordinate conversion 1467 console.log(`[PlateEnhancer] containerToVideoCoords: input=(${selection.x.toFixed(0)},${selection.y.toFixed(0)} ${selection.width.toFixed(0)}x${selection.height.toFixed(0)}) display=(${displayX.toFixed(0)},${displayY.toFixed(0)} ${displayWidth.toFixed(0)}x${displayHeight.toFixed(0)}) scale=${scaleX.toFixed(3)} relative=(${relativeX.toFixed(0)},${relativeY.toFixed(0)}) => video=(${videoX},${videoY} ${videoW}x${videoH}) videoSize=${selection.videoWidth}x${selection.videoHeight}`); 1468 1469 // Clamp to video bounds 1470 return { 1471 x: Math.max(0, Math.min(videoX, selection.videoWidth - videoW)), 1472 y: Math.max(0, Math.min(videoY, selection.videoHeight - videoH)), 1473 width: Math.min(videoW, selection.videoWidth), 1474 height: Math.min(videoH, selection.videoHeight) 1475 }; 1476 } 1477 1478 /** 1479 * Render selection box on overlay 1480 */ 1481 renderSelection(cameraId, overlay, selection) { 1482 // Remove existing selection box 1483 const existing = overlay.querySelector('.plate-enhancer-selection'); 1484 if (existing) existing.remove(); 1485 1486 const box = document.createElement('div'); 1487 box.className = 'plate-enhancer-selection'; 1488 box.style.left = `${selection.x}px`; 1489 box.style.top = `${selection.y}px`; 1490 box.style.width = `${selection.width}px`; 1491 box.style.height = `${selection.height}px`; 1492 1493 // Add resize handles 1494 ['nw', 'ne', 'sw', 'se'].forEach(pos => { 1495 const handle = document.createElement('div'); 1496 handle.className = `plate-enhancer-handle ${pos}`; 1497 handle.addEventListener('mousedown', (e) => this.startResize(e, cameraId, pos)); 1498 box.appendChild(handle); 1499 }); 1500 1501 // Add camera label 1502 const label = document.createElement('div'); 1503 label.className = 'plate-enhancer-camera-label'; 1504 label.textContent = this.getCameraDisplayName(cameraId); 1505 box.appendChild(label); 1506 1507 // Add size indicator (shows video pixel dimensions) 1508 const sizeIndicator = document.createElement('div'); 1509 sizeIndicator.className = 'plate-enhancer-size-indicator'; 1510 box.appendChild(sizeIndicator); 1511 this.updateSizeIndicator(cameraId, selection, sizeIndicator); 1512 1513 // Make box draggable 1514 box.addEventListener('mousedown', (e) => { 1515 if (e.target === box) { 1516 this.startDrag(e, cameraId); 1517 } 1518 }); 1519 1520 overlay.appendChild(box); 1521 } 1522 1523 /** 1524 * Get display name for camera 1525 */ 1526 getCameraDisplayName(cameraId) { 1527 const names = { 1528 'front': 'Front', 1529 'back': 'Back', 1530 'left_repeater': 'Left', 1531 'right_repeater': 'Right', 1532 'left_pillar': 'Left Pillar', 1533 'right_pillar': 'Right Pillar' 1534 }; 1535 return names[cameraId] || cameraId; 1536 } 1537 1538 /** 1539 * Update size indicator to show video pixel dimensions and warn if too large 1540 * @param {string} cameraId - Camera identifier 1541 * @param {Object} selection - Selection object with container dimensions 1542 * @param {HTMLElement} indicator - The size indicator element 1543 */ 1544 updateSizeIndicator(cameraId, selection, indicator) { 1545 const videoCoords = this.containerToVideoCoords(selection); 1546 const area = videoCoords.width * videoCoords.height; 1547 1548 // Classification thresholds (based on 4x ESRGAN processing time) 1549 // Optimal: < 40000 pixels (200x200 or smaller) - fast processing 1550 // Large: 40000-100000 pixels - slower, will show warning 1551 // Very large: > 100000 pixels - significantly slower 1552 const isLarge = area > 40000; 1553 const isVeryLarge = area > 100000; 1554 1555 indicator.textContent = `${videoCoords.width}Ã${videoCoords.height}px`; 1556 indicator.classList.toggle('warning', isLarge && !isVeryLarge); 1557 indicator.classList.toggle('critical', isVeryLarge); 1558 1559 if (isVeryLarge) { 1560 indicator.title = 'Very large selection - processing will be slow. Try selecting just the plate area.'; 1561 } else if (isLarge) { 1562 indicator.title = 'Large selection - processing may be slower than usual.'; 1563 } else { 1564 indicator.title = 'Selection size in video pixels'; 1565 } 1566 } 1567 1568 /** 1569 * Get size classification for a selection (used in enhance dialog) 1570 * @param {Object} selection - Selection object 1571 * @returns {Object} { area, classification, warning } 1572 */ 1573 getSelectionSizeInfo(selection) { 1574 const videoCoords = this.containerToVideoCoords(selection); 1575 const area = videoCoords.width * videoCoords.height; 1576 1577 let classification = 'optimal'; 1578 let warning = null; 1579 1580 if (area > 100000) { 1581 classification = 'critical'; 1582 warning = `Selection is ${videoCoords.width}Ã${videoCoords.height}px (${Math.round(area/1000)}k pixels). Processing will be significantly slower. Consider selecting just the license plate area for faster results.`; 1583 } else if (area > 40000) { 1584 classification = 'large'; 1585 warning = `Selection is ${videoCoords.width}Ã${videoCoords.height}px. Processing may take longer than usual.`; 1586 } 1587 1588 return { width: videoCoords.width, height: videoCoords.height, area, classification, warning }; 1589 } 1590 1591 /** 1592 * Start dragging selection box 1593 */ 1594 startDrag(e, cameraId) { 1595 e.preventDefault(); 1596 e.stopPropagation(); 1597 1598 const selection = this.selections.get(cameraId); 1599 if (!selection) return; 1600 1601 this.isDraggingOrResizing = true; 1602 1603 const startX = e.clientX; 1604 const startY = e.clientY; 1605 const origX = selection.x; 1606 const origY = selection.y; 1607 1608 const onMove = (moveEvent) => { 1609 const dx = moveEvent.clientX - startX; 1610 const dy = moveEvent.clientY - startY; 1611 1612 selection.x = Math.max(0, Math.min(origX + dx, selection.containerWidth - selection.width)); 1613 selection.y = Math.max(0, Math.min(origY + dy, selection.containerHeight - selection.height)); 1614
1615 const overlay = this.overlays.get(cameraId); 1616 const box = overlay.querySelector('.plate-enhancer-selection'); 1617 if (box) { 1618 box.style.left = `${selection.x}px`; 1619 box.style.top = `${selection.y}px`; 1620 } 1621 }; 1622 1623 const onUp = () => { 1624 document.removeEventListener('mousemove', onMove); 1625 document.removeEventListener('mouseup', onUp); 1626 // Delay clearing flag so the click event doesn't fire 1627 setTimeout(() => { 1628 this.isDraggingOrResizing = false; 1629 }, 50); 1630 }; 1631 1632 document.addEventListener('mousemove', onMove); 1633 document.addEventListener('mouseup', onUp); 1634 } 1635 1636 /** 1637 * Start resizing selection box 1638 */ 1639 startResize(e, cameraId, handle) { 1640 e.preventDefault(); 1641 e.stopPropagation(); 1642 1643 const selection = this.selections.get(cameraId); 1644 if (!selection) return; 1645 1646 const overlay = this.overlays.get(cameraId); 1647 const box = overlay?.querySelector('.plate-enhancer-selection'); 1648 if (!box) return; 1649 1650 this.isDraggingOrResizing = true; 1651 1652 // Get the container's position for coordinate conversion 1653 const container = overlay.parentElement; 1654 const containerRect = container.getBoundingClientRect(); 1655 1656 // Store initial state 1657 const startMouseX = e.clientX; 1658 const startMouseY = e.clientY; 1659 const origX = selection.x; 1660 const origY = selection.y; 1661 const origW = selection.width; 1662 const origH = selection.height; 1663 1664 const minSize = 20; 1665 1666 const onMove = (moveEvent) => { 1667 const dx = moveEvent.clientX - startMouseX; 1668 const dy = moveEvent.clientY - startMouseY; 1669 1670 let newX = origX; 1671 let newY = origY; 1672 let newW = origW; 1673 let newH = origH; 1674 1675 // East edge (right) 1676 if (handle.includes('e')) { 1677 newW = Math.max(minSize, origW + dx); 1678 } 1679 // West edge (left) 1680 if (handle.includes('w')) { 1681 const proposedW = origW - dx; 1682 if (proposedW >= minSize) { 1683 newW = proposedW; 1684 newX = origX + dx; 1685 } 1686 } 1687 // South edge (bottom) 1688 if (handle.includes('s')) { 1689 newH = Math.max(minSize, origH + dy); 1690 } 1691 // North edge (top) 1692 if (handle.includes('n')) { 1693 const proposedH = origH - dy; 1694 if (proposedH >= minSize) { 1695 newH = proposedH; 1696 newY = origY + dy; 1697 } 1698 } 1699 1700 // Constrain to container bounds 1701 newX = Math.max(0, newX); 1702 newY = Math.max(0, newY); 1703 if (newX + newW > selection.containerWidth) { 1704 newW = selection.containerWidth - newX; 1705 } 1706 if (newY + newH > selection.containerHeight) { 1707 newH = selection.containerHeight - newY; 1708 } 1709 1710 // Update selection 1711 selection.x = newX; 1712 selection.y = newY; 1713 selection.width = newW; 1714 selection.height = newH; 1715 1716 // Update DOM 1717 box.style.left = `${newX}px`; 1718 box.style.top = `${newY}px`; 1719 box.style.width = `${newW}px`; 1720 box.style.height = `${newH}px`; 1721 1722 // Update size indicator 1723 const sizeIndicator = box.querySelector('.plate-enhancer-size-indicator'); 1724 if (sizeIndicator) { 1725 this.updateSizeIndicator(cameraId, selection, sizeIndicator); 1726 } 1727 }; 1728 1729 const onUp = () => { 1730 document.removeEventListener('mousemove', onMove); 1731 document.removeEventListener('mouseup', onUp); 1732 // Delay clearing flag so the click event doesn't fire 1733 setTimeout(() => { 1734 this.isDraggingOrResizing = false; 1735 }, 50); 1736 }; 1737 1738 document.addEventListener('mousemove', onMove); 1739 document.addEventListener('mouseup', onUp); 1740 } 1741 1742 /** 1743 * Handle keyboard events during selection 1744 */ 1745 handleKeyDown(e) { 1746 if (!this.isSelecting) return; 1747 1748 if (e.key === 'Escape') { 1749 this.cancelSelection(); 1750 } else if (e.key === 'Enter') { 1751 if (this.selections.size > 0) { 1752 this.confirmSelection(); 1753 } 1754 } 1755 } 1756 1757 /** 1758 * Cancel selection mode 1759 */ 1760 cancelSelection() { 1761 this.isSelecting = false;
1762 this.isDraggingOrResizing = false; 1763 this.selections.clear(); 1764 this.removeOverlays(); 1765 document.removeEventListener('keydown', this.handleKeyDown); 1766 // Remove button highlight 1767 this.setButtonActive(false); 1768 } 1769 1770 /** 1771 * Clear all selections and reset state (called when switching events) 1772 */ 1773 clearAllSelections() { 1774 // Cancel any active selection 1775 this.isSelecting = false; 1776 this.isDraggingOrResizing = false; 1777 this.selections.clear(); 1778 this.removeOverlays(); 1779 document.removeEventListener('keydown', this.handleKeyDown); 1780 1781 // Close any open dialogs 1782 const dialogs = document.querySelectorAll('.plate-enhancer-dialog, .plate-enhancer-backdrop, .plate-enhancer-results'); 1783 dialogs.forEach(el => el.remove()); 1784 1785 // Reset processing state (but don't interrupt active processing - that's blocked separately) 1786 this.shouldCancel = true; 1787 1788 // Remove button highlight 1789 this.setButtonActive(false); 1790 1791 console.log('[PlateEnhancer] Cleared all selections and state'); 1792 } 1793 1794 /** 1795 * Confirm selection and show unified enhancement interface 1796 */ 1797 confirmSelection() { 1798 this.isSelecting = false; 1799 document.removeEventListener('keydown', this.handleKeyDown); 1800 1801 // Store the reference time (where user drew the box) 1802 const vp = window.app?.videoPlayer; 1803 if (vp && vp.getCurrentAbsoluteTime) { 1804 this.referenceTime = vp.getCurrentAbsoluteTime(); 1805 } else { 1806 const clipMarking = window.app?.clipMarking; 1807 if (clipMarking) { 1808 this.referenceTime = clipMarking.getAbsoluteTime(); 1809 } else { 1810 this.referenceTime = vp ? vp.getCurrentTime() : 0; 1811 } 1812 } 1813 1814 // Convert manual selections to detection objects for unified interface 1815 const detections = []; 1816 this.selections.forEach((selection, cameraId) => { 1817 const video = this.getVideoElement(cameraId); 1818 if (!video) return; 1819 1820 // Convert display coordinates to video coordinates 1821 const container = video.closest('.video-container'); 1822 if (!container) return; 1823 1824 const containerRect = container.getBoundingClientRect(); 1825 const videoDisplay = this.getVideoDisplayRect(video, containerRect); 1826 1827 // Scale from display to video coordinates 1828 const scaleX = video.videoWidth / videoDisplay.width; 1829 const scaleY = video.videoHeight / videoDisplay.height; 1830 1831 const bbox = { 1832 x: Math.round((selection.x - videoDisplay.x) * scaleX), 1833 y: Math.round((selection.y - videoDisplay.y) * scaleY), 1834 width: Math.round(selection.width * scaleX), 1835 height: Math.round(selection.height * scaleY) 1836 }; 1837 1838 // Clamp to video bounds 1839 bbox.x = Math.max(0, Math.min(bbox.x, video.videoWidth - bbox.width)); 1840 bbox.y = Math.max(0, Math.min(bbox.y, video.videoHeight - bbox.height)); 1841 1842 detections.push({ 1843 cameraId, 1844 bbox, 1845 confidence: 1.0, // Manual selection = 100% confidence 1846 videoDims: { width: video.videoWidth, height: video.videoHeight }, 1847 isManual: true, 1848 detectionTime: this.referenceTime 1849 }); 1850 }); 1851 1852 if (detections.length === 0) { 1853 this.showToast('No valid selections found'); 1854 this.cancelSelection(); 1855 return; 1856 } 1857 1858 // Remove selection overlays before showing cycling UI 1859 this.removeOverlays(); 1860 1861 // Use the unified cycling UI 1862 this.cyclingState = { 1863 isActive: true, 1864 detections, 1865 currentIndex: 0, 1866 adjustedBbox: null, 1867 detectionTime: this.referenceTime // Time when selection was made 1868 }; 1869 1870 this.showPlateCyclingUI(); 1871 } 1872 1873 /** 1874 * Show dialog to select time range 1875 * @param {Object|null} marks - IN/OUT marks if set { inPoint, outPoint } 1876 * @param {boolean} selectionOutsideMarks - Whether the selection was drawn outside the mark range 1877 */ 1878 showTimeRangeDialog(marks = null, selectionOutsideMarks = false) { 1879 const backdrop = document.createElement('div'); 1880 backdrop.className = 'plate-enhancer-dialog-backdrop'; 1881 1882 const hasMarks = marks && marks.inPoint !== null && marks.outPoint !== null; 1883 const marksDuration = hasMarks ? (marks.outPoint - marks.inPoint) : null; 1884 const marksDurationStr = marksDuration ? marksDuration.toFixed(1) : null; 1885 1886 // Check for very long durations 1887 const isLongDuration = marksDuration && marksDuration > 15; 1888 const isVeryLongDuration = marksDuration && marksDuration > 30; 1889 const estimatedFrames = marksDuration ? Math.ceil(marksDuration * 30) : 0; 1890
1891 // Check selection sizes and get warnings 1892 let sizeWarningHtml = ''; 1893 let hasLargeSelection = false; 1894 for (const [cameraId, selection] of this.selections) { 1895 const sizeInfo = this.getSelectionSizeInfo(selection); 1896 if (sizeInfo.warning) { 1897 hasLargeSelection = true; 1898 const cameraName = this.getCameraDisplayName(cameraId); 1899 const severityClass = sizeInfo.classification === 'critical' ? 'plate-enhancer-warning-severe' : ''; 1900 sizeWarningHtml += ` 1901 <div class="plate-enhancer-warning ${severityClass}"> 1902 ð <strong>${cameraName}:</strong> ${sizeInfo.warning} 1903 </div> 1904 `; 1905 } 1906 } 1907 1908 const dialog = document.createElement('div'); 1909 dialog.className = 'plate-enhancer-dialog'; 1910 dialog.innerHTML = ` 1911 <h3>ð Enhancement Options</h3> 1912 ${sizeWarningHtml} 1913 ${hasMarks ? ` 1914 ${selectionOutsideMarks ? ` 1915 <div class="plate-enhancer-warning"> 1916 ð <strong>Selection outside time range</strong> - You drew the selection at ${this.referenceTime.toFixed(1)}s, which is outside your IN/OUT marks. The plate position may differ. For best results, scrub to within your marked range before selecting. 1917 </div> 1918 ` : ''} 1919 <div class="plate-enhancer-section"> 1920 <div class="plate-enhancer-section-header">ð Time Range</div> 1921 <p class="plate-enhancer-help-text">Using your IN/OUT marks (${marksDurationStr}s â ~${estimatedFrames} frames)</p> 1922 <div class="plate-enhancer-marks-info"> 1923 <span class="plate-enhancer-mark-badge">IN: ${marks.inPoint.toFixed(1)}s</span> 1924 <span class="plate-enhancer-mark-badge">OUT: ${marks.outPoint.toFixed(1)}s</span> 1925 </div> 1926 ${isVeryLongDuration ? ` 1927 <div class="plate-enhancer-warning plate-enhancer-warning-severe"> 1928 â ï¸ <strong>Very long duration!</strong> Processing ${estimatedFrames} frames may take several minutes and could slow down your browser. Consider using a shorter range (5-10 seconds is usually enough). 1929 </div> 1930 ` : isLongDuration ? ` 1931 <div class="plate-enhancer-warning"> 1932 â±ï¸ Longer duration means more frames to process. This may take a while. 5-10 seconds is usually sufficient for good results. 1933 </div> 1934 ` : ''} 1935 </div> 1936 ` : ` 1937 <div class="plate-enhancer-section"> 1938 <div class="plate-enhancer-section-header">â±ï¸ Analysis Duration</div> 1939 <p class="plate-enhancer-help-text">More time = more frames to combine, but slower processing</p> 1940 <div class="plate-enhancer-time-presets"> 1941 <button class="plate-enhancer-preset-btn" data-seconds="2">2 sec<span class="preset-hint">Quick</span></button> 1942 <button class="plate-enhancer-preset-btn selected" data-seconds="5">5 sec<span class="preset-hint">Recommended</span></button> 1943 <button class="plate-enhancer-preset-btn" data-seconds="10">10 sec<span class="preset-hint">Thorough</span></button> 1944 </div> 1945 <p class="plate-enhancer-tip">ð¡ Tip: Use IN/OUT marks (I and O keys) for precise control</p> 1946 </div> 1947 `} 1948 <div class="plate-enhancer-dialog-actions"> 1949 <button class="plate-enhancer-btn plate-enhancer-btn-secondary" data-action="cancel">Cancel</button> 1950 <button class="plate-enhancer-btn plate-enhancer-btn-primary" data-action="start">ð Start Enhancement</button> 1951 </div> 1952 `; 1953 1954 let selectedSeconds = 5; 1955 1956 // Handle preset selection (only if not using marks) 1957 if (!hasMarks) { 1958 dialog.querySelectorAll('.plate-enhancer-preset-btn').forEach(btn => { 1959 btn.addEventListener('click', () => { 1960 dialog.querySelectorAll('.plate-enhancer-preset-btn').forEach(b => b.classList.remove('selected')); 1961 btn.classList.add('selected'); 1962 selectedSeconds = parseInt(btn.dataset.seconds); 1963 }); 1964 }); 1965 } 1966 1967 // Handle cancel 1968 dialog.querySelector('[data-action="cancel"]').addEventListener('click', () => { 1969 backdrop.remove(); 1970 dialog.remove(); 1971 this.cancelSelection(); 1972 }); 1973 1974 // Handle start 1975 dialog.querySelector('[data-action="start"]').addEventListener('click', async () => { 1976 backdrop.remove(); 1977 dialog.remove(); 1978 1979 let startTime, endTime; 1980 1981 if (hasMarks) { 1982 // Use IN/OUT marks 1983 startTime = marks.inPoint; 1984 endTime = marks.outPoint; 1985 } else { 1986 // Calculate time range centered on reference time (where user drew the selection) 1987 // Use referenceTime to ensure we process frames around where the plate was visible 1988 const vp = window.app?.videoPlayer; 1989 // Get total duration (getTotalDuration is async!) 1990 let totalDuration = 600; // Default fallback 1991 if (vp?.getTotalDuration) { 1992 try { 1993 const duration = await vp.getTotalDuration(); 1994 if (duration && Number.isFinite(duration) && duration > 0) { 1995 totalDuration = duration; 1996 } 1997 } catch (e) { 1998 console.warn('[PlateEnhancer] Failed to get total duration:', e); 1999 } 2000 } 2001 const halfRange = selectedSeconds / 2; 2002 startTime = Math.max(0, this.referenceTime - halfRange); 2003 endTime = Math.min(this.referenceTime + halfRange, totalDuration); // Clamp to video end 2004 } 2005 2006 // Always use 4x super-resolution upscaling for best OCR results 2007 const enableMLUpscale = true; 2008 const upscaleScale = 4; 2009 console.log('[PlateEnhancer] Using 4x super-resolution upscale'); 2010 2011 this.startProcessing(startTime, endTime, enableMLUpscale, upscaleScale); 2012 }); 2013 2014 // Handle backdrop click 2015 backdrop.addEventListener('click', () => { 2016 backdrop.remove(); 2017 dialog.remove(); 2018 this.cancelSelection(); 2019 }); 2020 2021 document.body.appendChild(backdrop); 2022 document.body.appendChild(dialog); 2023 } 2024 2025 /** 2026 * Remove all selection overlays 2027 */ 2028 removeOverlays() { 2029 this.overlays.forEach((overlay) => { 2030 overlay.remove(); 2031 }); 2032 this.overlays.clear(); 2033 2034 // Also remove instruction element if it exists (appended to body) 2035 if (this.instructionElement) { 2036 this.instructionElement.remove(); 2037 this.instructionElement = null; 2038 } 2039 } 2040 2041 /** 2042 * Check if OpenCV is truly loaded and ready to use 2043 * @returns {boolean} 2044 */ 2045 isOpenCVReady() { 2046 try { 2047 // Check if cv global exists and has key functions 2048 return typeof cv !== 'undefined' && 2049 typeof cv.Mat === 'function' && 2050 typeof cv.getBuildInformation === 'function'; 2051 } catch (e) { 2052 return false;
2053 } 2054 } 2055 2056 /** 2057 * Load OpenCV.js on demand 2058 */ 2059 async loadOpenCV() { 2060 if (this.isOpenCVLoaded && this.isOpenCVReady()) return true; 2061 if (this.isLoadingOpenCV) { 2062 // Wait for existing load with timeout 2063 return new Promise((resolve) => { 2064 let waited = 0; 2065 const check = setInterval(() => { 2066 waited += 100; 2067 if (this.isOpenCVLoaded) { 2068 clearInterval(check); 2069 resolve(true); 2070 } else if (waited > 30000) { 2071 clearInterval(check); 2072 resolve(false); 2073 } 2074 }, 100); 2075 }); 2076 } 2077 2078 this.isLoadingOpenCV = true; 2079 2080 // Show loading indicator 2081 const loading = document.createElement('div'); 2082 loading.className = 'plate-enhancer-loading-opencv'; 2083 loading.innerHTML = ` 2084 <div class="spinner"></div> 2085 <p>Loading image processing library...</p> 2086 <p style="font-size: 12px; color: var(--text-secondary, #888); margin-top: 8px;">First time may take 10-20 seconds</p> 2087 `; 2088 document.body.appendChild(loading); 2089 2090 try { 2091 await new Promise((resolve, reject) => { 2092 // Timeout after 30 seconds 2093 const timeout = setTimeout(() => { 2094 reject(new Error('OpenCV.js load timeout')); 2095 }, 30000); 2096 2097 const script = document.createElement('script'); 2098 script.src = 'https://docs.opencv.org/4.8.0/opencv.js'; 2099 script.async = true; 2100 2101 script.onload = () => { 2102 // OpenCV.js sets up a cv object but needs to be initialized 2103 if (typeof cv !== 'undefined') { 2104 if (cv.getBuildInformation) { 2105 // Already ready 2106 clearTimeout(timeout); 2107 resolve(); 2108 } else { 2109 // Wait for onRuntimeInitialized 2110 cv.onRuntimeInitialized = () => { 2111 clearTimeout(timeout); 2112 resolve(); 2113 }; 2114 } 2115 } else { 2116 clearTimeout(timeout); 2117 reject(new Error('OpenCV.js failed to load')); 2118 } 2119 }; 2120 2121 script.onerror = () => { 2122 clearTimeout(timeout); 2123 reject(new Error('Failed to load OpenCV.js')); 2124 }; 2125 document.head.appendChild(script); 2126 }); 2127 2128 this.isOpenCVLoaded = true; 2129 console.log('[PlateEnhancer] OpenCV.js loaded successfully'); 2130 return true; 2131 } catch (error) { 2132 console.error('[PlateEnhancer] Failed to load OpenCV.js:', error); 2133 return false; 2134 } finally { 2135 loading.remove(); 2136 this.isLoadingOpenCV = false; 2137 } 2138 } 2139 2140 /** 2141 * Start the enhancement processing 2142 * @param {number} startTime - Start time in seconds 2143 * @param {number} endTime - End time in seconds 2144 * @param {boolean} enableMLUpscale - Whether to apply super-resolution upscaling 2145 * @param {number} upscaleScale - Scale factor for upscaling (2, 3, or 4) 2146 */ 2147 async startProcessing(startTime, endTime, enableMLUpscale = false, upscaleScale = 2) { 2148 this.removeOverlays(); 2149 this.isProcessing = true; 2150 this.shouldCancel = false; 2151 2152 // Clear previous enhancement data (new extraction = new original) 2153 this.lastExtraction = null; 2154 this.lastEnhanced = null; 2155 this.originalEnhanced = null; 2156 2157 // Ensure OpenCV is loaded before proceeding 2158 if (!this.isOpenCVReady()) { 2159 console.log('[PlateEnhancer] OpenCV not ready, loading...'); 2160 const loaded = await this.loadOpenCV(); 2161 if (!loaded || !this.isOpenCVReady()) { 2162 this.showError('Failed to load image processing library. Please try again.'); 2163 this.isProcessing = false;
2164 return; 2165 } 2166 } 2167 2168 // Save current video position to restore after extraction 2169 // Use ?? instead of || to handle time=0 correctly (0 is falsy but valid) 2170 const vp = window.app?.videoPlayer; 2171 const savedTime = vp?.getCurrentAbsoluteTime?.() ?? vp?.getCurrentTime?.() ?? 0; 2172 console.log(`[PlateEnhancer] Saving video position: ${savedTime.toFixed(2)}s`); 2173 2174 // Show progress modal 2175 const backdrop = document.createElement('div'); 2176 backdrop.className = 'plate-enhancer-dialog-backdrop'; 2177 2178 const progress = document.createElement('div'); 2179 progress.className = 'plate-enhancer-progress'; 2180 progress.innerHTML = ` 2181 <h3>Enhancing License Plate</h3> 2182 <div class="plate-enhancer-progress-status">Preparing...</div> 2183 <div class="plate-enhancer-progress-bar"> 2184 <div class="plate-enhancer-progress-fill" style="width: 0%"></div> 2185 </div> 2186 <div class="plate-enhancer-progress-percent">0%</div> 2187 <div class="plate-enhancer-progress-cancel"> 2188 <button class="plate-enhancer-btn plate-enhancer-btn-secondary">Cancel</button> 2189 </div> 2190 `; 2191 2192 progress.querySelector('button').addEventListener('click', () => { 2193 this.shouldCancel = true; 2194 }); 2195 2196 document.body.appendChild(backdrop); 2197 document.body.appendChild(progress); 2198 2199 const updateProgress = (status, percent) => { 2200 progress.querySelector('.plate-enhancer-progress-status').textContent = status; 2201 progress.querySelector('.plate-enhancer-progress-fill').style.width = `${percent}%`; 2202 progress.querySelector('.plate-enhancer-progress-percent').textContent = `${Math.round(percent)}%`; 2203 }; 2204 2205 try { 2206 // Import the tracker and stacker modules dynamically 2207 const tracker = new RegionTracker(); 2208 const stacker = new FrameStacker(); 2209 2210 // Enable ML upscaling if requested (using ESRGAN-Thick for best quality) 2211 if (enableMLUpscale) { 2212 stacker.enableMLUpscale(true, upscaleScale, 'thick'); 2213 stacker.postProcessSharpening = true; // Crisp text edges 2214 stacker.enablePreProcessing = true; // MAX QUALITY: contrast + sharpening before ESRGAN 2215 } 2216 2217 // ALWAYS use real multi-frame stacking - dramatically improves low-light results 2218 stacker.useRealStacking = true; 2219 stacker.stackingMethod = 'sigma-mean'; // Best: combines data while rejecting outliers 2220 2221 // Extract frames and track regions 2222 const results = await this.extractAndTrack(startTime, endTime, updateProgress, tracker); 2223 2224 if (this.shouldCancel) { 2225 throw new Error('Cancelled'); 2226 } 2227 2228 // Get video enhancement settings (brightness, contrast, saturation) 2229 const videoEnhancements = this.getVideoEnhancementSettings(); 2230 2231 // Store extraction data for re-processing (manual frame selection, region adjustment) 2232 this.lastExtraction = { 2233 regions: results.regions, 2234 frames: results.frames, 2235 settings: { 2236 enableMLUpscale, 2237 upscaleScale, 2238 videoEnhancements, 2239 startTime, 2240 endTime 2241 } 2242 }; 2243 2244 // Process frames through stacker 2245 updateProgress(`Ranking ${results.regions.length} frames by sharpness...`, 78); 2246 await new Promise(r => setTimeout(r, 47)); // Let UI update 2247 2248 const enhanced = await stacker.process(results.regions, results.frames, (status, pct) => { 2249 updateProgress(status, 78 + pct * 0.2); // 78-98% 2250 }, videoEnhancements); 2251 2252 if (this.shouldCancel) { 2253 throw new Error('Cancelled'); 2254 } 2255 2256 // Store enhanced result 2257 this.lastEnhanced = enhanced; 2258 // Also store as original (never overwritten during reprocessing) 2259 // This preserves all frames so user can try different selections
2260 this.originalEnhanced = enhanced; 2261 2262 updateProgress('Complete!', 100); 2263 2264 // Clean up progress modal 2265 backdrop.remove(); 2266 progress.remove(); 2267 2268 // Show results with re-processing options 2269 await this.showResults(enhanced, true); 2270 2271 } catch (error) { 2272 backdrop.remove(); 2273 progress.remove(); 2274 2275 if (error.message !== 'Cancelled') { 2276 console.error('[PlateEnhancer] Processing error:', error); 2277 this.showError(`Enhancement failed: ${error.message}`); 2278 } 2279 } finally { 2280 this.isProcessing = false; 2281 this.isSelecting = false; 2282 this.activeCamera = null; 2283 this.selections.clear(); 2284 document.removeEventListener('keydown', this.handleKeyDown); 2285 2286 // Restore video position after extraction 2287 if (vp && savedTime !== undefined) { 2288 console.log(`[PlateEnhancer] Restoring video position: ${savedTime.toFixed(2)}s`); 2289 try { 2290 if (vp.seekToEventTime) { 2291 await vp.seekToEventTime(savedTime); 2292 } 2293 } catch (e) { 2294 console.warn('[PlateEnhancer] Could not restore video position:', e); 2295 } 2296 } 2297 } 2298 } 2299 2300 /** 2301 * Generate time samples with variable density - dense near reference, sparse far away 2302 * @param {number} referenceTime - Center point (where user made selection) 2303 * @param {number} startTime - Start of range 2304 * @param {number} endTime - End of range 2305 * @returns {Array} Array of time values to sample 2306 */ 2307 generateVariableDensitySamples(referenceTime, startTime, endTime) { 2308 const samples = new Set(); 2309 samples.add(referenceTime); // Always include reference 2310 2311 // Capture EVERY frame at 36fps across the entire time range 2312 // Tesla cameras record at 36fps, so this gets every possible frame 2313 const frameInterval = 1 / 36; 2314 2315 // Add small buffer to endTime to avoid sampling exactly at video end 2316 // This prevents seek issues when at the tail of the video 2317 const safeEndTime = endTime - 0.05; // 50ms buffer from end 2318 2319 // Sample from start to end at native framerate 2320 for (let time = startTime; time <= safeEndTime; time += frameInterval) { 2321 samples.add(time); 2322 } 2323 2324 console.log(`[PlateEnhancer] Capturing every frame: ${samples.size} samples over ${(safeEndTime - startTime).toFixed(1)}s (ref: ${referenceTime.toFixed(2)}s)`); 2325 2326 // Sort samples chronologically 2327 return Array.from(samples).sort((a, b) => a - b); 2328 } 2329 2330 /** 2331 * Extract frames and track region across time 2332 * Tracks from reference frame (where user drew box) both forward and backward 2333 * Uses variable density sampling - dense near reference, sparse further away 2334 */ 2335 async extractAndTrack(startTime, endTime, updateProgress, tracker) { 2336 // Get reference time (where user drew the selection box) 2337 // Clamp to be within the time range - user may have drawn selection outside IN/OUT marks 2338 // Use ?? instead of || to handle referenceTime of 0 correctly (0 is falsy but valid) 2339 let referenceTime = this.referenceTime ?? ((startTime + endTime) / 2); 2340 if (referenceTime < startTime) { 2341 console.log(`[PlateEnhancer] Reference time ${referenceTime.toFixed(2)}s is before start ${startTime.toFixed(2)}s, using start`); 2342 referenceTime = startTime; 2343 } else if (referenceTime > endTime) { 2344 console.log(`[PlateEnhancer] Reference time ${referenceTime.toFixed(2)}s is after end ${endTime.toFixed(2)}s, using end`); 2345 referenceTime = endTime; 2346 } 2347 2348 // Generate variable density time samples 2349 const allTimeSamples = this.generateVariableDensitySamples(referenceTime, startTime, endTime); 2350 const forwardSamples = allTimeSamples.filter(t => t >= referenceTime); 2351 const backwardSamples = allTimeSamples.filter(t => t <= referenceTime).reverse(); // ref first, then earlier times 2352 2353 console.log(`[PlateEnhancer] Variable density sampling: ${allTimeSamples.length} total samples`); 2354 console.log(`[PlateEnhancer] Zone breakdown: ~${Math.round(0.5 * 36 * 2)} near (±0.5s @36fps), ~${Math.round(1.5 * 15 * 2)} mid (±2s @15fps), rest sparse (@6fps)`); 2355 2356 const allRegions = []; 2357 const allFrames = []; 2358 2359 let cameraIndex = 0; 2360 const totalCameras = this.selections.size; 2361 2362 for (const [cameraId, selection] of this.selections) { 2363 const cameraName = this.getCameraDisplayName(cameraId); 2364 const cameraBaseProgress = (cameraIndex / totalCameras) * 75; 2365 const cameraProgressRange = 75 / totalCameras; 2366 2367 const video = this.getVideoElement(cameraId); 2368 if (!video) continue; 2369 2370 // Convert selection from container coords to video coords 2371 const videoSelection = this.containerToVideoCoords(selection); 2372 2373 // Extract reference frame first 2374 updateProgress(`${cameraName}: Extracting reference frame...`, cameraBaseProgress); 2375 const refFrame = await this.extractFrame(video, referenceTime, cameraId); 2376 if (!refFrame) { 2377 console.warn(`[PlateEnhancer] Could not extract reference frame for ${cameraId}`); 2378 cameraIndex++; 2379 continue; 2380 } 2381 2382 // Build frame list using variable density samples 2383 const forwardFrames = []; 2384 const backwardFrames = []; 2385 let extractedCount = 0; 2386 const totalFramesToExtract = forwardSamples.length + backwardSamples.length - 1; // -1 because ref is in both 2387 2388 // Forward frames (reference to end) - includes reference frame 2389 for (let i = 0; i < forwardSamples.length; i++) { 2390 if (this.shouldCancel) return { regions: [], frames: [] }; 2391 const time = forwardSamples[i]; 2392 2393 const frame = i === 0 ? refFrame : await this.extractFrame(video, time, cameraId); 2394 if (frame) { 2395 forwardFrames.push({ imageData: frame, time, cameraId }); 2396 } 2397 extractedCount++; 2398 2399 // Update progress during extraction 2400 if (extractedCount % 10 === 0) { 2401 const extractProgress = (extractedCount / totalFramesToExtract) * 0.5; 2402 updateProgress(`${cameraName}: Extracting frames (${extractedCount}/${totalFramesToExtract})...`, 2403 cameraBaseProgress + extractProgress * cameraProgressRange); 2404 } 2405 } 2406 2407 // Backward frames: start from reference and go backwards 2408 backwardFrames.push({ imageData: refFrame, time: referenceTime, cameraId }); // Start with reference 2409 2410 for (let i = 1; i < backwardSamples.length; i++) { 2411 if (this.shouldCancel) return { regions: [], frames: [] }; 2412 const time = backwardSamples[i]; 2413 2414 const frame = await this.extractFrame(video, time, cameraId); 2415 if (frame) { 2416 backwardFrames.push({ imageData: frame, time, cameraId }); 2417 } 2418 extractedCount++; 2419 2420 // Update progress during extraction 2421 if (extractedCount % 10 === 0) { 2422 const extractProgress = (extractedCount / totalFramesToExtract) * 0.5; 2423 updateProgress(`${cameraName}: Extracting frames (${extractedCount}/${totalFramesToExtract})...`, 2424 cameraBaseProgress + extractProgress * cameraProgressRange); 2425 } 2426 } 2427 2428 // Tracking phase 2429 const trackingStart = cameraBaseProgress + 0.55 * cameraProgressRange; 2430 const trackingRange = 0.35 * cameraProgressRange; 2431 2432 updateProgress(`${cameraName}: Tracking forward (${forwardFrames.length} frames)...`, trackingStart); 2433 const forwardTracked = await tracker.track(forwardFrames, videoSelection, (pct) => { 2434 updateProgress(`${cameraName}: Tracking forward (${Math.round(pct * forwardFrames.length)}/${forwardFrames.length})...`, 2435 trackingStart + pct * trackingRange * 0.5); 2436 }); 2437 2438 updateProgress(`${cameraName}: Tracking backward (${backwardFrames.length} frames)...`, 2439 trackingStart + trackingRange * 0.5); 2440 const backwardTracked = await tracker.track(backwardFrames, videoSelection, (pct) => { 2441 updateProgress(`${cameraName}: Tracking backward (${Math.round(pct * backwardFrames.length)}/${backwardFrames.length})...`, 2442 trackingStart + trackingRange * 0.5 + pct * trackingRange * 0.5); 2443 }); 2444 2445 updateProgress(`${cameraName}: Cropping regions...`, 2446 cameraBaseProgress + 0.9 * cameraProgressRange); 2447 2448 // Combine results chronologically: 2449 // backwardTracked = [ref, ref-1, ref-2, ...] -> reverse to [ref-2, ref-1, ref] 2450 // forwardTracked = [ref, ref+1, ref+2, ...] 2451 // Combined = [ref-2, ref-1, ref, ref+1, ref+2, ...] (but ref appears twice) 2452 2453 const backwardReversed = backwardTracked.slice().reverse(); // [oldest, ..., ref] 2454 const backwardFramesReversed = backwardFrames.slice().reverse(); // [oldest, ..., ref] 2455 2456 // Remove duplicate reference from backward (it's also in forward) 2457 const allTracked = [...backwardReversed.slice(0, -1), ...forwardTracked]; 2458 const allCameraFrames = [...backwardFramesReversed.slice(0, -1), ...forwardFrames]; 2459 2460 // Crop tracked regions from frames, validating each region 2461 let croppedCount = 0; 2462 let droppedCount = 0; 2463 for (let i = 0; i < allCameraFrames.length; i++) { 2464 const region = allTracked[i]; 2465 if (region) { 2466 // Validate region hasn't drifted too far or changed size too much 2467 // This catches cases where tracking latched onto a different object 2468 const isValid = this.validateTrackedRegion(region, videoSelection); 2469 2470 if (!isValid) { 2471 droppedCount++; 2472 continue; // Skip this frame - likely tracking the wrong object 2473 } 2474 2475 const cropped = this.cropRegion(allCameraFrames[i].imageData, region); 2476 if (cropped) { 2477 allRegions.push({ 2478 imageData: cropped, 2479 time: allCameraFrames[i].time, 2480 cameraId: allCameraFrames[i].cameraId, 2481 region: region 2482 }); 2483 croppedCount++; 2484 } 2485 } 2486 } 2487 2488 if (droppedCount > 0) { 2489 console.log(`[PlateEnhancer] ${cameraName}: Dropped ${droppedCount} frames (tracking drift detected)`); 2490 } 2491 2492 console.log(`[PlateEnhancer] ${cameraName}: Extracted ${allCameraFrames.length} frames, cropped ${croppedCount} regions`); 2493 allFrames.push(...allCameraFrames); 2494 cameraIndex++; 2495 } 2496 2497 return { regions: allRegions, frames: allFrames }; 2498 } 2499 2500 /** 2501 * Get video element for camera ID 2502 */ 2503 getVideoElement(cameraId) { 2504 const mapping = { 2505 'front': 'videoFront', 2506 'back': 'videoBack', 2507 'left_repeater': 'videoLeft', 2508 'right_repeater': 'videoRight', 2509 'left_pillar': 'videoLeftPillar', 2510 'right_pillar': 'videoRightPillar' 2511 }; 2512 return document.getElementById(mapping[cameraId]); 2513 } 2514 2515 /** 2516 * Extract a single frame from video at given absolute event time 2517 * Uses app's seekToEventTime to handle clip boundaries properly 2518 */ 2519 async extractFrame(video, time, cameraId) { 2520 const vp = window.app?.videoPlayer; 2521 2522 // Validate time is within bounds (prevent seeking past video end) 2523 if (vp) { 2524 // Use cached duration (synchronous) to avoid slow async call in loop 2525 let totalDuration = null; 2526 if (vp.cachedClipDurations?.length > 0) { 2527 totalDuration = vp.cachedClipDurations.reduce((a, b) => a + b, 0); 2528 } 2529 if (totalDuration && Number.isFinite(totalDuration) && totalDuration > 0) { 2530 // Clamp time to be within valid range with 0.1s buffer from end 2531 const maxValidTime = Math.max(0, totalDuration - 0.1); 2532 if (time > maxValidTime) { 2533 console.log(`[PlateEnhancer] extractFrame: Clamping time ${time.toFixed(2)}s to ${maxValidTime.toFixed(2)}s (duration: ${totalDuration.toFixed(2)}s)`); 2534 time = maxValidTime; 2535 } 2536 if (time < 0) { 2537 time = 0; 2538 } 2539 } 2540 } 2541 2542 // Use the app's seek functionality to handle clip boundaries 2543 if (vp && vp.seekToEventTime) { 2544 try { 2545 // seekToEventTime loads the right clip and sets currentTime 2546 await vp.seekToEventTime(time); 2547 2548 // Get the video element (may have changed if clip was loaded) 2549 const currentVideo = this.getVideoElement(cameraId); 2550 if (!currentVideo) { 2551 console.warn(`[PlateEnhancer] No video element found for ${cameraId}`); 2552 return null; 2553 } 2554 2555 // Wait for the seek to actually complete (seeked event) 2556 // We must wait because seekToEventTime sets currentTime but doesn't wait for seek completion 2557 await new Promise((resolve) => { 2558 let resolved = false;
2559 2560 const cleanup = () => { 2561 currentVideo.removeEventListener('seeked', onSeeked); 2562 currentVideo.removeEventListener('canplay', onCanPlay); 2563 clearTimeout(timeout); 2564 }; 2565 2566 const onSeeked = () => { 2567 if (resolved) return; 2568 resolved = true; 2569 cleanup(); 2570 resolve(); 2571 }; 2572 2573 const onCanPlay = () => { 2574 if (resolved) return; 2575 resolved = true; 2576 cleanup(); 2577 resolve(); 2578 }; 2579 2580 currentVideo.addEventListener('seeked', onSeeked); 2581 currentVideo.addEventListener('canplay', onCanPlay); 2582 2583 // Timeout after 3 seconds 2584 const timeout = setTimeout(() => { 2585 if (resolved) return; 2586 resolved = true; 2587 cleanup(); 2588 console.warn(`[PlateEnhancer] Seek timeout for ${cameraId}, proceeding anyway`); 2589 resolve(); 2590 }, 3000); 2591 2592 // If video is not currently seeking and is ready, check after a short delay 2593 // (the seeked event may have already fired before we added listeners) 2594 setTimeout(() => { 2595 if (!resolved && !currentVideo.seeking && currentVideo.readyState >= 2) { 2596 resolved = true; 2597 cleanup(); 2598 resolve(); 2599 } 2600 }, 100); 2601 }); 2602 2603 // Extra small delay to ensure frame is painted 2604 await new Promise(r => setTimeout(r, 30)); 2605 2606 // Verify video is ready 2607 if (currentVideo.readyState < 2 || currentVideo.videoWidth === 0) { 2608 console.warn(`[PlateEnhancer] Video not ready for ${cameraId}: readyState=${currentVideo.readyState}, size=${currentVideo.videoWidth}x${currentVideo.videoHeight}`); 2609 return null; 2610 } 2611 2612 // Draw to canvas 2613 const canvas = document.createElement('canvas'); 2614 canvas.width = currentVideo.videoWidth; 2615 canvas.height = currentVideo.videoHeight; 2616 const ctx = canvas.getContext('2d'); 2617 2618 // Apply video enhancements to source frames if enabled 2619 if (this.applyVideoEnhancementsToSource) { 2620 const settings = this.getVideoEnhancementSettings(); 2621 this.applyVideoEnhancementsToCanvas(ctx, settings); 2622 } 2623 2624 ctx.drawImage(currentVideo, 0, 0); 2625 2626 // Reset filter for subsequent operations 2627 ctx.filter = 'none'; 2628 2629 return ctx.getImageData(0, 0, canvas.width, canvas.height); 2630 } catch (error) { 2631 console.error(`[PlateEnhancer] Seek error:`, error); 2632 return null; 2633 } 2634 } 2635 2636 // Fallback: direct seek (only works within current clip) 2637 // Store reference to this for callback 2638 const self = this; 2639 2640 return new Promise((resolve) => { 2641 video.currentTime = time; 2642 2643 const onSeeked = () => { 2644 video.removeEventListener('seeked', onSeeked); 2645 2646 // Draw to canvas 2647 const canvas = document.createElement('canvas'); 2648 canvas.width = video.videoWidth; 2649 canvas.height = video.videoHeight; 2650 const ctx = canvas.getContext('2d'); 2651 2652 // Apply video enhancements to source frames if enabled 2653 if (self.applyVideoEnhancementsToSource) { 2654 const settings = self.getVideoEnhancementSettings(); 2655 self.applyVideoEnhancementsToCanvas(ctx, settings); 2656 } 2657 2658 ctx.drawImage(video, 0, 0); 2659 2660 // Reset filter 2661 ctx.filter = 'none'; 2662 2663 resolve(ctx.getImageData(0, 0, canvas.width, canvas.height)); 2664 }; 2665 2666 video.addEventListener('seeked', onSeeked); 2667 2668 // Timeout fallback 2669 setTimeout(() => { 2670 video.removeEventListener('seeked', onSeeked); 2671 resolve(null); 2672 }, 2000); 2673 }); 2674 } 2675 2676 /** 2677 * Crop a region from image data 2678 */ 2679 cropRegion(imageData, region) { 2680 const { x, y, width, height } = region; 2681 2682 // Validate region bounds 2683 if (x < 0 || y < 0 || x + width > imageData.width || y + height > imageData.height) { 2684 return null; 2685 } 2686 2687 const canvas = document.createElement('canvas'); 2688 canvas.width = width; 2689 canvas.height = height; 2690 const ctx = canvas.getContext('2d'); 2691 2692 // Create temporary canvas with source image 2693 const srcCanvas = document.createElement('canvas'); 2694 srcCanvas.width = imageData.width; 2695 srcCanvas.height = imageData.height; 2696 const srcCtx = srcCanvas.getContext('2d'); 2697 srcCtx.putImageData(imageData, 0, 0); 2698 2699 // Crop 2700 ctx.drawImage(srcCanvas, x, y, width, height, 0, 0, width, height); 2701 2702 return ctx.getImageData(0, 0, width, height); 2703 } 2704 2705 /** 2706 * Validate that a tracked region hasn't drifted too far from the original selection 2707 * This catches cases where optical flow tracking latched onto a different object 2708 * @param {Object} region - The tracked region { x, y, width, height } 2709 * @param {Object} originalSelection - The original user selection in video coordinates 2710 * @returns {boolean} - True if region is valid, false if it should be dropped 2711 */ 2712 validateTrackedRegion(region, originalSelection) { 2713 if (!region || !originalSelection) return false;
2714 2715 // 1. Check if region center has moved too far from original center 2716 const originalCenterX = originalSelection.x + originalSelection.width / 2; 2717 const originalCenterY = originalSelection.y + originalSelection.height / 2; 2718 const regionCenterX = region.x + region.width / 2; 2719 const regionCenterY = region.y + region.height / 2; 2720 2721 const dx = Math.abs(regionCenterX - originalCenterX); 2722 const dy = Math.abs(regionCenterY - originalCenterY); 2723 2724 // Allow movement up to 3x the original selection size 2725 // This accommodates vehicles moving across frame over multiple seconds 2726 const maxMoveX = originalSelection.width * 3; 2727 const maxMoveY = originalSelection.height * 3; 2728 2729 if (dx > maxMoveX || dy > maxMoveY) { 2730 return false; // Center has moved too far - likely tracking wrong object 2731 } 2732 2733 // 2. Check if region size has changed too dramatically 2734 const widthRatio = region.width / originalSelection.width; 2735 const heightRatio = region.height / originalSelection.height; 2736 2737 // Allow size to vary between 0.25x and 4x original 2738 // Vehicles can grow as they approach or shrink as they recede 2739 if (widthRatio < 0.25 || widthRatio > 4.0 || heightRatio < 0.25 || heightRatio > 4.0) { 2740 return false; // Size changed too much - likely tracking wrong object 2741 } 2742 2743 // 3. Check aspect ratio hasn't changed dramatically 2744 const originalAspect = originalSelection.width / originalSelection.height; 2745 const regionAspect = region.width / region.height; 2746 const aspectRatio = regionAspect / originalAspect; 2747 2748 // Aspect ratio should stay within 0.5x to 2x 2749 if (aspectRatio < 0.5 || aspectRatio > 2.0) { 2750 return false; // Aspect ratio changed too much 2751 } 2752 2753 return true; 2754 } 2755 2756 /** 2757 * Show results modal 2758 * @param {Object} enhanced - Enhanced results 2759 * @param {boolean} canReprocess - Whether re-processing is available 2760 */ 2761 async showResults(enhanced, canReprocess = false) { 2762 // Check if user is Pro - free users get watermarked images 2763 // Per spec NMC-4702 2764 const isPro = await this.isProUser(); 2765 2766 // For free users, watermark all displayed images to prevent right-click save 2767 if (!isPro) { 2768 console.log('[PlateEnhancer] Free user - applying watermarks to displayed images'); 2769 2770 // Watermark combined result 2771 if (enhanced.combined?.dataUrl) { 2772 enhanced.combined.displayUrl = await this.addWatermark(enhanced.combined.dataUrl); 2773 } 2774 2775 // Watermark method results (including upscaled versions) 2776 if (enhanced.methods) { 2777 for (const key of Object.keys(enhanced.methods)) { 2778 if (enhanced.methods[key]?.dataUrl) { 2779 enhanced.methods[key].displayUrl = await this.addWatermark(enhanced.methods[key].dataUrl); 2780 } 2781 // Also watermark upscaled version if available 2782 if (enhanced.methods[key]?.upscaledDataUrl) { 2783 enhanced.methods[key].upscaledDisplayUrl = await this.addWatermark(enhanced.methods[key].upscaledDataUrl); 2784 } 2785 } 2786 } 2787 2788 // Watermark upscaled 2789 if (enhanced.upscaled?.dataUrl) { 2790 enhanced.upscaled.displayUrl = await this.addWatermark(enhanced.upscaled.dataUrl); 2791 } 2792 2793 // Watermark top frames 2794 if (enhanced.topFrames) { 2795 for (const frame of enhanced.topFrames) { 2796 if (frame.dataUrl) { 2797 frame.displayUrl = await this.addWatermark(frame.dataUrl); 2798 } 2799 } 2800 } 2801 } 2802 2803 // Helper to get display URL (watermarked for free, original for pro) 2804 const getDisplayUrl = (item) => item.displayUrl || item.dataUrl; 2805 2806 const backdrop = document.createElement('div'); 2807 backdrop.className = 'plate-enhancer-dialog-backdrop'; 2808 2809 const modal = document.createElement('div'); 2810 modal.className = 'plate-enhancer-results'; 2811 2812 // Create header 2813 const header = document.createElement('div'); 2814 header.className = 'plate-enhancer-results-header'; 2815 header.innerHTML = ` 2816 <h3>Enhancement Results</h3> 2817 <button class="plate-enhancer-close-btn"> 2818 <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> 2819 <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/> 2820 </svg> 2821 </button> 2822 `; 2823 modal.appendChild(header); 2824 2825 // Main preview 2826 const mainPreview = document.createElement('div'); 2827 mainPreview.className = 'plate-enhancer-main-preview'; 2828 const mainImg = document.createElement('img'); 2829 mainImg.src = getDisplayUrl(enhanced.combined); 2830 mainPreview.appendChild(mainImg); 2831 2832 const mainLabel = document.createElement('div'); 2833 mainLabel.className = 'plate-enhancer-main-preview-label'; 2834 mainLabel.textContent = 'Combined Result (click thumbnails below to compare)'; 2835 mainPreview.appendChild(mainLabel); 2836 modal.appendChild(mainPreview); 2837 2838 // OCR Section with ensemble OCR 2839 const ocrSection = document.createElement('div'); 2840 ocrSection.className = 'plate-enhancer-ocr-section'; 2841 ocrSection.innerHTML = ` 2842 <div class="plate-enhancer-ocr-header">
2843 <span class="plate-enhancer-ocr-label"> 2844 <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: middle; margin-right: 4px;"> 2845 <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/> 2846 </svg> 2847 AI Detected Text 2848 </span> 2849 <span class="plate-enhancer-ocr-status">Initializing...</span> 2850 </div> 2851 <div class="plate-enhancer-ocr-progress" style="display: none;"> 2852 <div class="plate-enhancer-ocr-progress-bar"></div> 2853 <span class="plate-enhancer-ocr-progress-text">Loading OCR model...</span> 2854 </div> 2855 <div class="plate-enhancer-ocr-result"> 2856 <span class="plate-enhancer-ocr-text">-</span> 2857 <button class="plate-enhancer-ocr-copy" title="Copy text" style="display: none;"> 2858 <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> 2859 <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/> 2860 </svg> 2861 </button> 2862 </div> 2863 <div class="plate-enhancer-ocr-warning" style="display: none;"> 2864 <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
2865 <path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/> 2866 </svg> 2867 <span>Low confidence - verify manually</span> 2868 </div> 2869 <div class="plate-enhancer-ocr-disclaimer">Results may not be accurate - verify manually</div> 2870 `; 2871 modal.appendChild(ocrSection); 2872 2873 // Progress callback for OCR 2874 const updateOCRProgress = (status, progress) => { 2875 const statusEl = ocrSection.querySelector('.plate-enhancer-ocr-status'); 2876 const progressContainer = ocrSection.querySelector('.plate-enhancer-ocr-progress'); 2877 const progressBar = ocrSection.querySelector('.plate-enhancer-ocr-progress-bar'); 2878 const progressText = ocrSection.querySelector('.plate-enhancer-ocr-progress-text'); 2879 2880 statusEl.textContent = status; 2881 2882 if (progress !== null && progress >= 0) { 2883 progressContainer.style.display = 'block'; 2884 progressBar.style.width = `${Math.round(progress * 100)}%`; 2885 progressText.textContent = `${status} (${Math.round(progress * 100)}%)`; 2886 } else { 2887 progressContainer.style.display = 'none'; 2888 } 2889 }; 2890 2891 // Store method elements for OCR updates (will be populated later) 2892 let methodElements = {}; 2893 2894 // Run ensemble OCR on all enhancement methods 2895 this.runEnsembleOCR(enhanced, updateOCRProgress).then(result => { 2896 const statusEl = ocrSection.querySelector('.plate-enhancer-ocr-status'); 2897 const textEl = ocrSection.querySelector('.plate-enhancer-ocr-text'); 2898 const copyBtn = ocrSection.querySelector('.plate-enhancer-ocr-copy'); 2899 const warningEl = ocrSection.querySelector('.plate-enhancer-ocr-warning'); 2900 2901 // Hide progress bar when done 2902 const progressContainer = ocrSection.querySelector('.plate-enhancer-ocr-progress'); 2903 if (progressContainer) progressContainer.style.display = 'none'; 2904 2905 if (result.error) { 2906 statusEl.textContent = 'OCR unavailable'; 2907 textEl.textContent = '-'; 2908 } else if (result.text) { 2909 statusEl.textContent = `Confidence: ${result.confidence.toFixed(0)}%`; 2910 textEl.textContent = result.text; 2911 copyBtn.style.display = 'inline-flex'; 2912 2913 // Show warning if low confidence 2914 if (result.confidence < 50) { 2915 warningEl.style.display = 'flex'; 2916 } 2917 2918 // Store OCR result for Pro features 2919 this.lastOCRResult = result; 2920 2921 // Copy OCR text (Pro feature) 2922 copyBtn.addEventListener('click', async () => { 2923 try { 2924 await navigator.clipboard.writeText(result.text); 2925 this.showToast('Text copied!'); 2926 } catch (e) { 2927 console.error('Copy failed:', e); 2928 } 2929 }); 2930 2931 // Update per-method OCR results if available 2932 if (result.methodResults && Object.keys(methodElements).length > 0) { 2933 const votedText = result.text.replace(/[\s-]/g, ''); 2934 for (const [methodKey, methodResult] of Object.entries(result.methodResults)) { 2935 const el = methodElements[methodKey]; 2936 if (el) { 2937 const ocrTextEl = el.querySelector('.ocr-text'); 2938 const ocrConfEl = el.querySelector('.ocr-confidence'); 2939 if (ocrTextEl && methodResult.text) { 2940 ocrTextEl.textContent = methodResult.text; 2941 const cleanText = methodResult.text.replace(/[\s-]/g, ''); 2942 const isCorrect = cleanText === votedText; 2943 ocrTextEl.classList.toggle('correct', isCorrect); 2944 ocrTextEl.classList.toggle('incorrect', !isCorrect); 2945 if (ocrConfEl) { 2946 ocrConfEl.textContent = `${methodResult.confidence.toFixed(0)}%`; 2947 } 2948 } else if (ocrTextEl) { 2949 ocrTextEl.textContent = '-'; 2950 } 2951 } 2952 } 2953 } 2954 } else { 2955 statusEl.textContent = 'No text detected'; 2956 textEl.textContent = '-'; 2957 } 2958 }); 2959 2960 // Enhancement Methods Section (if available) 2961 if (enhanced.methods) { 2962 const methodsSection = document.createElement('div'); 2963 methodsSection.className = 'plate-enhancer-methods-section'; 2964 methodsSection.innerHTML = ` 2965 <div class="plate-enhancer-methods-label">Enhancement Methods (click to preview, 4x badge = upscaled available):</div> 2966 <div class="plate-enhancer-methods-grid"></div> 2967 `; 2968 modal.appendChild(methodsSection); 2969 2970 const methodsGrid = methodsSection.querySelector('.plate-enhancer-methods-grid');
2971 const methodOrder = ['sigmaClipped', 'msrClahe', 'weightedMean', 'bestFrame', 'bilateral', 'ensemble', 'luckyRegions']; 2972 const methodNames = { 2973 sigmaClipped: 'Sigma Clipped', 2974 msrClahe: 'MSR+CLAHE', 2975 weightedMean: 'Weighted Mean', 2976 bestFrame: 'Best Frame', 2977 bilateral: 'Bilateral', 2978 ensemble: 'Ensemble', 2979 luckyRegions: 'ð¯ Lucky' 2980 }; 2981 2982 // Use outer methodElements for OCR updates 2983 2984 methodOrder.forEach((methodKey, index) => { 2985 const method = enhanced.methods[methodKey]; 2986 if (!method) return; 2987 2988 const displayUrl = method.displayUrl || method.dataUrl; 2989 const hasUpscaled = !!method.upscaledDataUrl; 2990 const methodThumb = document.createElement('div'); 2991 methodThumb.className = 'plate-enhancer-method-thumb' + (methodKey === 'luckyRegions' ? ' lucky-method' : '') + (methodKey === 'ensemble' ? ' selected' : '') + (hasUpscaled ? ' has-upscaled' : ''); 2992 methodThumb.dataset.src = method.dataUrl; // Keep original for downloads 2993 methodThumb.dataset.displaySrc = displayUrl; // Watermarked for display 2994 methodThumb.dataset.methodKey = methodKey; 2995 methodThumb.dataset.label = method.name || methodNames[methodKey]; 2996 methodThumb.dataset.showingUpscaled = 'false'; 2997 if (hasUpscaled) { 2998 methodThumb.dataset.upscaledSrc = method.upscaledDataUrl; 2999 methodThumb.dataset.upscaledSharpness = method.upscaledSharpness; 3000 } 3001 methodThumb.innerHTML = ` 3002 <img src="${displayUrl}" alt="${methodNames[methodKey]}"> 3003 <div class="plate-enhancer-method-name">${methodNames[methodKey]}</div> 3004 ${hasUpscaled ? '<div class="plate-enhancer-upscale-badge" title="Click badge to toggle 4x upscaled">4x</div>' : ''} 3005 <div class="plate-enhancer-method-ocr"> 3006 <span class="ocr-text">...</span> 3007 <span class="ocr-confidence"></span> 3008 </div> 3009 `; 3010 methodElements[methodKey] = methodThumb; 3011 methodsGrid.appendChild(methodThumb); 3012 3013 // Click on 4x badge to toggle upscaled view 3014 const badge = methodThumb.querySelector('.plate-enhancer-upscale-badge'); 3015 if (badge) { 3016 badge.addEventListener('click', (e) => { 3017 e.stopPropagation(); 3018 const isShowingUpscaled = methodThumb.dataset.showingUpscaled === 'true'; 3019 const img = methodThumb.querySelector('img'); 3020 const nameDiv = methodThumb.querySelector('.plate-enhancer-method-name'); 3021 3022 // Use watermarked version for display (upscaledDisplayUrl), original for downloads (upscaledDataUrl) 3023 const upscaledDisplay = method.upscaledDisplayUrl || method.upscaledDataUrl; 3024 3025 if (isShowingUpscaled) { 3026 // Switch to original 3027 img.src = displayUrl; 3028 nameDiv.textContent = methodNames[methodKey]; 3029 badge.classList.remove('active'); 3030 methodThumb.dataset.showingUpscaled = 'false'; 3031 // Update main preview if this is selected 3032 if (methodThumb.classList.contains('selected')) { 3033 mainImg.src = displayUrl; 3034 mainLabel.textContent = `${method.name} (Sharpness: ${method.sharpness.toFixed(1)})`; 3035 } 3036 } else { 3037 // Switch to upscaled (watermarked for display) 3038 img.src = upscaledDisplay; 3039 nameDiv.textContent = methodNames[methodKey] + ' 4x'; 3040 badge.classList.add('active'); 3041 methodThumb.dataset.showingUpscaled = 'true'; 3042 // Update main preview if this is selected 3043 if (methodThumb.classList.contains('selected')) { 3044 mainImg.src = upscaledDisplay; 3045 mainLabel.textContent = `${method.name} 4x (Sharpness: ${method.upscaledSharpness?.toFixed(1) || 'N/A'})`; 3046 } 3047 } 3048 }); 3049 } 3050 3051 // Click to preview 3052 methodThumb.addEventListener('click', (e) => { 3053 // Don't trigger if clicking on badge 3054 if (e.target.classList.contains('plate-enhancer-upscale-badge')) return; 3055 3056 // Determine which version to show (use watermarked for display) 3057 const isShowingUpscaled = methodThumb.dataset.showingUpscaled === 'true'; 3058 const upscaledDisplay = method.upscaledDisplayUrl || method.upscaledDataUrl; 3059 const srcToShow = isShowingUpscaled ? upscaledDisplay : displayUrl; 3060 const sharpnessToShow = isShowingUpscaled ? method.upscaledSharpness : method.sharpness; 3061 const labelSuffix = isShowingUpscaled ? ' 4x' : ''; 3062 3063 // Update main preview 3064 mainImg.src = srcToShow; 3065 mainLabel.textContent = `${method.name}${labelSuffix} (Sharpness: ${sharpnessToShow?.toFixed(1) || 'N/A'})`; 3066 3067 // Update selection state 3068 methodsGrid.querySelectorAll('.plate-enhancer-method-thumb').forEach(t => t.classList.remove('selected')); 3069 methodThumb.classList.add('selected'); 3070 3071 // Also update thumbnail selection (for consistency) 3072 thumbsContainer?.querySelectorAll('.plate-enhancer-thumbnail').forEach(t => t.classList.remove('selected')); 3073 }); 3074 }); 3075 } 3076 3077 // Use originalEnhanced for frame selection (preserves all frames across reprocessing) 3078 // After reprocessing, enhanced.topFrames only has selected frames, but we want all original frames 3079 const allFrames = this.originalEnhanced?.topFrames || enhanced.topFrames; 3080 3081 // Frame selection controls (only if re-processing is available) 3082 let frameSelectionControls = null; 3083 if (canReprocess && this.lastExtraction) { 3084 frameSelectionControls = document.createElement('div'); 3085 frameSelectionControls.className = 'plate-enhancer-frame-selection'; 3086 frameSelectionControls.innerHTML = ` 3087 <div class="plate-enhancer-frame-selection-header">
3088 <span class="plate-enhancer-frame-selection-icon">ðï¸</span> 3089 <span class="plate-enhancer-frame-selection-label">Manual Frame Selection</span> 3090 </div> 3091 <p class="plate-enhancer-frame-selection-desc"> 3092 Not happy with the result? Uncheck blurry or misaligned frames below, then re-process with only the good ones. 3093 </p> 3094 <div class="plate-enhancer-frame-selection-controls"> 3095 <button class="plate-enhancer-btn-small" data-action="select-all" title="Include all frames - best for Lucky Regions">â All</button> 3096 <button class="plate-enhancer-btn-small" data-action="select-none" title="Deselect all frames">â None</button> 3097 <button class="plate-enhancer-btn-small" data-action="select-top5" title="Select only the 5 sharpest">Top 5</button> 3098 <button class="plate-enhancer-btn-small" data-action="select-top10" title="Select the 10 sharpest - good balance">Top 10</button> 3099 <button class="plate-enhancer-btn-small" data-action="select-top15" title="Select the 15 sharpest - great for Lucky">Top 15</button> 3100 <span class="plate-enhancer-frame-count">0 / ${allFrames.length} selected</span> 3101 <button class="plate-enhancer-btn plate-enhancer-btn-primary plate-enhancer-btn-reprocess" data-action="reprocess" disabled title="Need at least 2 frames"> 3102 ð Re-process Selected 3103 </button> 3104 </div> 3105 `; 3106 modal.appendChild(frameSelectionControls); 3107 } 3108 3109 // Thumbnails 3110 const thumbsLabel = document.createElement('div'); 3111 thumbsLabel.className = 'plate-enhancer-thumbnails-label'; 3112 thumbsLabel.textContent = canReprocess ? 'Source frames (click to preview, check to include):' : 'Source frames (ranked by sharpness):'; 3113 modal.appendChild(thumbsLabel); 3114 3115 const thumbsContainer = document.createElement('div'); 3116 thumbsContainer.className = 'plate-enhancer-thumbnails'; 3117 3118 // Add combined as first thumbnail 3119 const combinedThumb = this.createThumbnail(enhanced.combined, 'Combined', 'Stacked', true, false); 3120 combinedThumb.classList.add('combined', 'selected'); 3121 thumbsContainer.appendChild(combinedThumb); 3122 3123 // Add upscaled version if available (for comparison) 3124 if (enhanced.upscaled) { 3125 const scale = enhanced.upscaled.scale || 2; 3126 const upscaledThumb = this.createThumbnail(enhanced.upscaled, `${scale}x Upscaled`, 'Super-res', true, false); 3127 upscaledThumb.classList.add('upscaled'); 3128 thumbsContainer.appendChild(upscaledThumb); 3129 } 3130 3131 // Add ALL original frames with checkboxes if re-processing available 3132 // This uses allFrames (from originalEnhanced) so frames are never lost after reprocessing 3133 allFrames.forEach((frame, index) => { 3134 // Show sharpness score in sublabel for debugging 3135 const sharpLabel = frame.sharpness ? `${frame.sharpness.toFixed(0)}` : ''; 3136 const thumb = this.createThumbnail(frame, `#${index + 1}`, `${frame.cameraId} (${sharpLabel})`, false, canReprocess); 3137 thumb.dataset.frameIndex = index; 3138 thumb.dataset.sharpness = frame.sharpness || 0; 3139 // Pre-select all frames by default 3140 if (canReprocess) { 3141 const checkbox = thumb.querySelector('.plate-enhancer-frame-checkbox'); 3142 if (checkbox) checkbox.checked = true; 3143 } 3144 thumbsContainer.appendChild(thumb); 3145 }); 3146 3147 modal.appendChild(thumbsContainer); 3148 3149 // Update frame count display 3150 const updateFrameCount = () => { 3151 if (!frameSelectionControls) return; 3152 const checked = thumbsContainer.querySelectorAll('.plate-enhancer-frame-checkbox:checked').length; 3153 const total = allFrames.length; // Use allFrames (preserved across reprocessing) 3154 frameSelectionControls.querySelector('.plate-enhancer-frame-count').textContent = `${checked} / ${total} selected`; 3155 const reprocessBtn = frameSelectionControls.querySelector('[data-action="reprocess"]'); 3156 reprocessBtn.disabled = checked < 2; // Need at least 2 frames 3157 if (checked < 2) { 3158 reprocessBtn.title = 'Select at least 2 frames'; 3159 } else { 3160 reprocessBtn.title = ''; 3161 } 3162 }; 3163 3164 // Frame selection control handlers 3165 if (frameSelectionControls) { 3166 frameSelectionControls.querySelector('[data-action="select-all"]').addEventListener('click', () => { 3167 thumbsContainer.querySelectorAll('.plate-enhancer-frame-checkbox').forEach(cb => cb.checked = true); 3168 updateFrameCount(); 3169 }); 3170 3171 frameSelectionControls.querySelector('[data-action="select-none"]').addEventListener('click', () => { 3172 thumbsContainer.querySelectorAll('.plate-enhancer-frame-checkbox').forEach(cb => cb.checked = false); 3173 updateFrameCount(); 3174 }); 3175 3176 frameSelectionControls.querySelector('[data-action="select-top5"]').addEventListener('click', () => { 3177 const checkboxes = thumbsContainer.querySelectorAll('.plate-enhancer-frame-checkbox');
3178 checkboxes.forEach((cb, i) => cb.checked = i < 5); 3179 updateFrameCount(); 3180 }); 3181 3182 frameSelectionControls.querySelector('[data-action="select-top10"]').addEventListener('click', () => { 3183 const checkboxes = thumbsContainer.querySelectorAll('.plate-enhancer-frame-checkbox'); 3184 checkboxes.forEach((cb, i) => cb.checked = i < 10); 3185 updateFrameCount(); 3186 }); 3187 3188 frameSelectionControls.querySelector('[data-action="select-top15"]').addEventListener('click', () => { 3189 const checkboxes = thumbsContainer.querySelectorAll('.plate-enhancer-frame-checkbox'); 3190 checkboxes.forEach((cb, i) => cb.checked = i < 15); 3191 updateFrameCount(); 3192 }); 3193 3194 frameSelectionControls.querySelector('[data-action="reprocess"]').addEventListener('click', () => { 3195 // Get selected frame indices 3196 const selectedIndices = []; 3197 thumbsContainer.querySelectorAll('.plate-enhancer-frame-checkbox:checked').forEach(cb => { 3198 const thumb = cb.closest('.plate-enhancer-thumbnail'); 3199 if (thumb && thumb.dataset.frameIndex !== undefined) { 3200 selectedIndices.push(parseInt(thumb.dataset.frameIndex)); 3201 } 3202 }); 3203 3204 if (selectedIndices.length < 2) { 3205 this.showToast('Select at least 2 frames'); 3206 return; 3207 } 3208 3209 // Close current modal and re-process 3210 backdrop.remove(); 3211 modal.remove(); 3212 this.reprocessWithSelectedFrames(selectedIndices); 3213 }); 3214 3215 // Initial count 3216 updateFrameCount(); 3217 } 3218 3219 // Handle thumbnail clicks (for preview) and checkbox changes 3220 thumbsContainer.addEventListener('click', (e) => { 3221 // Handle checkbox clicks separately 3222 if (e.target.classList.contains('plate-enhancer-frame-checkbox')) { 3223 e.stopPropagation(); 3224 updateFrameCount(); 3225 return; 3226 } 3227 3228 const thumb = e.target.closest('.plate-enhancer-thumbnail'); 3229 if (thumb) { 3230 thumbsContainer.querySelectorAll('.plate-enhancer-thumbnail').forEach(t => t.classList.remove('selected')); 3231 thumb.classList.add('selected'); 3232 // Also clear method thumb selection for consistency 3233 modal.querySelectorAll('.plate-enhancer-method-thumb').forEach(t => t.classList.remove('selected')); 3234 // Use displaySrc (watermarked) for preview, keep src for downloads 3235 mainImg.src = thumb.dataset.displaySrc || thumb.dataset.src; 3236 mainLabel.textContent = thumb.dataset.label; 3237 } 3238 }); 3239 3240 // Actions 3241 const actions = document.createElement('div'); 3242 actions.className = 'plate-enhancer-results-actions'; 3243 actions.innerHTML = ` 3244 <div class="plate-enhancer-actions-row"> 3245 <button class="plate-enhancer-btn plate-enhancer-btn-secondary" data-action="crop-region" title="Draw a region on the preview to crop"> 3246 <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: middle; margin-right: 4px;"> 3247 <path d="M17 15h2V7c0-1.1-.9-2-2-2H9v2h8v8zM7 17V1H5v4H1v2h4v10c0 1.1.9 2 2 2h10v4h2v-4h4v-2H7z"/> 3248 </svg> 3249 Crop Region 3250 </button> 3251 <button class="plate-enhancer-btn plate-enhancer-btn-secondary" data-action="save-notes" title="Save OCR result to event notes (Pro)"> 3252 <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: middle; margin-right: 4px;"> 3253 <path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/> 3254 </svg> 3255 Save to Notes
3256 <span class="plate-enhancer-pro-badge">PRO</span> 3257 </button> 3258 <button class="plate-enhancer-btn plate-enhancer-btn-secondary" data-action="copy" title="Copy image to clipboard (Pro)"> 3259 <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: middle; margin-right: 4px;"> 3260 <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/> 3261 </svg> 3262 Copy Image 3263 <span class="plate-enhancer-pro-badge">PRO</span> 3264 </button> 3265 </div> 3266 <div class="plate-enhancer-actions-row"> 3267 <button class="plate-enhancer-btn plate-enhancer-btn-secondary" data-action="retry-shorter" title="Retry with ±2s timeframe"> 3268 <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: middle; margin-right: 4px;"> 3269 <path d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/> 3270 </svg> 3271 Retry Shorter 3272 </button> 3273 <button class="plate-enhancer-btn plate-enhancer-btn-secondary" data-action="detect-another" title="Run detection to find more plates"> 3274 <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: middle; margin-right: 4px;"> 3275 <path d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.516 6.516 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5Z"/> 3276 </svg> 3277 Detect Another 3278 </button> 3279 <button class="plate-enhancer-btn plate-enhancer-btn-secondary" data-action="download-all" title="Download all images as ZIP (Pro)"> 3280 Download All (ZIP) 3281 <span class="plate-enhancer-pro-badge">PRO</span> 3282 </button> 3283 <button class="plate-enhancer-btn plate-enhancer-btn-primary" data-action="download-selected" title="Download selected image (Pro)"> 3284 Download Selected 3285 <span class="plate-enhancer-pro-badge">PRO</span> 3286 </button> 3287 </div> 3288 `; 3289 modal.appendChild(actions); 3290 3291 // Save to Notes handler (Pro feature) 3292 actions.querySelector('[data-action="save-notes"]')?.addEventListener('click', async () => { 3293 if (!await this.checkProAccess('save to notes')) return; 3294 if (!this.lastOCRResult?.text) { 3295 this.showToast('No OCR result to save'); 3296 return; 3297 } 3298 // Save to notes via NotesManager 3299 if (window.app?.notesManager) { 3300 const currentEvent = window.app.eventBrowser?.selectedEvent; 3301 if (currentEvent) { 3302 const noteText = `License Plate: ${this.lastOCRResult.text} (${this.lastOCRResult.confidence.toFixed(0)}% confidence)`; 3303 await window.app.notesManager.addNote(currentEvent.id, noteText); 3304 this.showToast('Saved to notes!'); 3305 } else { 3306 this.showToast('No event selected', 'error'); 3307 } 3308 } else { 3309 this.showToast('Notes feature not available', 'error'); 3310 } 3311 }); 3312 3313 // Retry Shorter handler - reprocess existing frames with subset 3314 actions.querySelector('[data-action="retry-shorter"]')?.addEventListener('click', async () => { 3315 closeModal(); 3316 3317 // If we have topFrames from last enhancement, reprocess with shorter subset 3318 if (this.lastEnhanced?.topFrames && this.lastEnhanced.topFrames.length >= 2) { 3319 // Use first 5 frames (or all if less than 5) which are the sharpest 3320 const frameCount = Math.min(5, this.lastEnhanced.topFrames.length); 3321 const selectedIndices = Array.from({ length: frameCount }, (_, i) => i); 3322 await this.reprocessWithSelectedFrames(selectedIndices); 3323 } else { 3324 // No frames available - enter selection mode
3325 this.showToast('Please select a region to enhance', 'info'); 3326 this.startSelectionMode(); 3327 } 3328 }); 3329 3330 // Detect Another handler 3331 actions.querySelector('[data-action="detect-another"]')?.addEventListener('click', async () => { 3332 closeModal(); 3333 // Start plate selection mode 3334 await this.autoDetectPlates(); 3335 }); 3336 3337 // Download All handler (Pro feature) 3338 actions.querySelector('[data-action="download-all"]')?.addEventListener('click', async () => { 3339 if (!await this.checkProAccess('download all images')) return; 3340 await this.downloadAllAsZip(enhanced); 3341 }); 3342 3343 // Crop region functionality 3344 let cropOverlay = null; 3345 let cropSelection = null; 3346 let isCropping = false; 3347 let cropEscapeHandler = null; 3348 3349 // Helper to exit crop mode cleanly 3350 const exitCropMode = () => { 3351 if (cropOverlay) cropOverlay.remove(); 3352 cropOverlay = null; 3353 cropSelection = null; 3354 isCropping = false; 3355 const cropBtn = actions.querySelector('[data-action="crop-region"]'); 3356 cropBtn.classList.remove('crop-mode-active'); 3357 cropBtn.innerHTML = ` 3358 <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: middle; margin-right: 4px;"> 3359 <path d="M17 15h2V7c0-1.1-.9-2-2-2H9v2h8v8zM7 17V1H5v4H1v2h4v10c0 1.1.9 2 2 2h10v4h2v-4h4v-2H7z"/> 3360 </svg> 3361 Crop Region 3362 `; 3363 mainLabel.textContent = 'Combined Result (click thumbnails below to compare)'; 3364 if (cropEscapeHandler) { 3365 document.removeEventListener('keydown', cropEscapeHandler); 3366 cropEscapeHandler = null; 3367 } 3368 }; 3369 3370 actions.querySelector('[data-action="crop-region"]').addEventListener('click', () => { 3371 if (isCropping) { 3372 exitCropMode(); 3373 return; 3374 } 3375 3376 isCropping = true; 3377 const cropBtn = actions.querySelector('[data-action="crop-region"]'); 3378 cropBtn.innerHTML = ` 3379 <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: middle; margin-right: 4px;"> 3380 <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/> 3381 </svg> 3382 Cancel Crop (Esc) 3383 `; 3384 cropBtn.classList.add('crop-mode-active'); 3385 mainLabel.textContent = ''; 3386 3387 // Create crop overlay with instruction 3388 cropOverlay = document.createElement('div'); 3389 cropOverlay.className = 'plate-enhancer-crop-overlay'; 3390 3391 // Add instruction banner 3392 const instruction = document.createElement('div'); 3393 instruction.className = 'plate-enhancer-crop-instruction'; 3394 instruction.innerHTML = ` 3395 <div class="crop-instruction-icon">âï¸</div> 3396 <div class="crop-instruction-text"> 3397 <strong>Click and drag</strong> to select the area you want to crop 3398 </div> 3399 <div class="crop-instruction-hint">Press <kbd>Esc</kbd> or click the red button to cancel</div> 3400 `; 3401 cropOverlay.appendChild(instruction); 3402 3403 mainPreview.appendChild(cropOverlay); 3404 3405 // Add Escape key handler for crop mode 3406 cropEscapeHandler = (e) => { 3407 if (e.key === 'Escape' && isCropping) { 3408 e.preventDefault(); 3409 exitCropMode(); 3410 } 3411 }; 3412 document.addEventListener('keydown', cropEscapeHandler); 3413 3414 let startX, startY, selectionBox; 3415 3416 cropOverlay.addEventListener('mousedown', (e) => { 3417 // Hide instruction banner when user starts drawing 3418 const instructionEl = cropOverlay.querySelector('.plate-enhancer-crop-instruction'); 3419 if (instructionEl) instructionEl.remove(); 3420 3421 const rect = cropOverlay.getBoundingClientRect(); 3422 startX = e.clientX - rect.left; 3423 startY = e.clientY - rect.top; 3424 3425 // Remove existing selection 3426 if (selectionBox) selectionBox.remove(); 3427 3428 selectionBox = document.createElement('div'); 3429 selectionBox.className = 'plate-enhancer-crop-selection'; 3430 selectionBox.style.left = startX + 'px'; 3431 selectionBox.style.top = startY + 'px'; 3432 cropOverlay.appendChild(selectionBox); 3433 3434 const onMouseMove = (e) => { 3435 const currentX = e.clientX - rect.left; 3436 const currentY = e.clientY - rect.top; 3437 3438 const x = Math.min(startX, currentX); 3439 const y = Math.min(startY, currentY); 3440 const width = Math.abs(currentX - startX); 3441 const height = Math.abs(currentY - startY); 3442 3443 selectionBox.style.left = x + 'px'; 3444 selectionBox.style.top = y + 'px'; 3445 selectionBox.style.width = width + 'px'; 3446 selectionBox.style.height = height + 'px'; 3447 }; 3448 3449 const onMouseUp = (e) => { 3450 document.removeEventListener('mousemove', onMouseMove); 3451 document.removeEventListener('mouseup', onMouseUp); 3452 3453 const currentX = e.clientX - rect.left; 3454 const currentY = e.clientY - rect.top; 3455 3456 const x = Math.min(startX, currentX); 3457 const y = Math.min(startY, currentY); 3458 const width = Math.abs(currentX - startX); 3459 const height = Math.abs(currentY - startY); 3460 3461 if (width > 10 && height > 10) { 3462 // Valid selection - add apply button 3463 // Get the actual image rect (may be letterboxed within overlay) 3464 const imgRect = mainImg.getBoundingClientRect(); 3465 cropSelection = { 3466 x, y, width, height, 3467 overlayWidth: rect.width, 3468 overlayHeight: rect.height, 3469 imgRect: { left: imgRect.left, top: imgRect.top, width: imgRect.width, height: imgRect.height }, 3470 overlayRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height } 3471 }; 3472 3473 // Add button container to selection 3474 const btnContainer = document.createElement('div'); 3475 btnContainer.className = 'plate-enhancer-crop-buttons'; 3476 3477 // Cancel button 3478 const cancelBtn = document.createElement('button'); 3479 cancelBtn.className = 'plate-enhancer-crop-cancel'; 3480 cancelBtn.textContent = 'Cancel'; 3481 cancelBtn.addEventListener('mousedown', (e) => e.stopPropagation()); 3482 cancelBtn.addEventListener('click', (e) => { 3483 e.stopPropagation(); 3484 exitCropMode(); 3485 }); 3486 btnContainer.appendChild(cancelBtn); 3487 3488 // Apply button 3489 const applyBtn = document.createElement('button'); 3490 applyBtn.className = 'plate-enhancer-crop-apply'; 3491 applyBtn.textContent = 'Apply & Re-process'; 3492 applyBtn.addEventListener('mousedown', (e) => e.stopPropagation()); 3493 applyBtn.addEventListener('click', (e) => { 3494 e.stopPropagation(); 3495 // Close the modal and re-process with cropped region 3496 backdrop.remove(); 3497 modal.remove(); 3498 this.reprocessWithCroppedRegion(cropSelection, enhanced); 3499 }); 3500 btnContainer.appendChild(applyBtn); 3501 3502 selectionBox.appendChild(btnContainer); 3503 } else { 3504 // Too small - remove 3505 selectionBox.remove(); 3506 } 3507 }; 3508 3509 document.addEventListener('mousemove', onMouseMove); 3510 document.addEventListener('mouseup', onMouseUp); 3511 }); 3512 }); 3513 3514 // Handle close 3515 const closeModal = () => { 3516 backdrop.remove(); 3517 modal.remove(); 3518 }; 3519 3520 header.querySelector('.plate-enhancer-close-btn').addEventListener('click', closeModal); 3521 backdrop.addEventListener('click', closeModal); 3522 3523 // Handle copy to clipboard (Pro feature) 3524 actions.querySelector('[data-action="copy"]').addEventListener('click', async () => { 3525 if (!await this.checkProAccess('copy image')) return; 3526 // Check both thumbnail and method thumb selections 3527 const selectedThumb = thumbsContainer.querySelector('.plate-enhancer-thumbnail.selected'); 3528 const selectedMethod = modal.querySelector('.plate-enhancer-method-thumb.selected'); 3529 const selected = selectedThumb || selectedMethod; 3530 if (selected) { 3531 await this.copyToClipboard(selected.dataset.src); 3532 } else { 3533 this.showToast('No image selected', 'info'); 3534 } 3535 }); 3536 3537 // Handle downloads (Pro feature) 3538 actions.querySelector('[data-action="download-selected"]').addEventListener('click', async () => { 3539 if (!await this.checkProAccess('download image')) return; 3540 // Check both thumbnail and method thumb selections 3541 const selectedThumb = thumbsContainer.querySelector('.plate-enhancer-thumbnail.selected'); 3542 const selectedMethod = modal.querySelector('.plate-enhancer-method-thumb.selected'); 3543 const selected = selectedThumb || selectedMethod; 3544 if (selected) { 3545 this.downloadImage(selected.dataset.src, 'enhanced-plate.png'); 3546 } else { 3547 this.showToast('No image selected', 'info'); 3548 } 3549 }); 3550 3551 actions.querySelector('[data-action="download-all"]').addEventListener('click', async () => { 3552 if (!await this.checkProAccess('download all images')) return; 3553 await this.downloadAllAsZip(enhanced); 3554 }); 3555 3556 document.body.appendChild(backdrop); 3557 document.body.appendChild(modal); 3558 3559 // Block right-click on images for free users only 3560 this.setupImageRightClickProtection(modal); 3561 } 3562 3563 /** 3564 * Block right-click on images for free users, allow for Pro users 3565 * @param {HTMLElement} container - Container with images to protect 3566 */ 3567 async setupImageRightClickProtection(container) {
3568 const isPro = await this.isProUser(); 3569 if (isPro) return; // Pro users can right-click freely 3570 3571 // Block right-click on all images in the container 3572 container.addEventListener('contextmenu', (e) => { 3573 if (e.target.tagName === 'IMG') { 3574 e.preventDefault(); 3575 } 3576 }); 3577 } 3578 3579 /** 3580 * Create thumbnail element 3581 * @param {Object} frame - Frame data with dataUrl and optional displayUrl (watermarked) 3582 * @param {string} rank - Rank label 3583 * @param {string} cameraLabel - Camera identifier 3584 * @param {boolean} isCombined - Whether this is a combined/processed result 3585 * @param {boolean} showCheckbox - Whether to show selection checkbox 3586 */ 3587 createThumbnail(frame, rank, cameraLabel, isCombined = false, showCheckbox = false) { 3588 const thumb = document.createElement('div'); 3589 thumb.className = 'plate-enhancer-thumbnail'; 3590 if (showCheckbox) thumb.classList.add('selectable'); 3591 thumb.dataset.src = frame.dataUrl; // Original for downloads 3592 thumb.dataset.displaySrc = frame.displayUrl || frame.dataUrl; // Watermarked for display 3593 thumb.dataset.label = isCombined ? 'Combined Result' : `${rank} - ${this.getCameraDisplayName(cameraLabel)}`; 3594 3595 // Add checkbox for frame selection 3596 if (showCheckbox) { 3597 const checkbox = document.createElement('input'); 3598 checkbox.type = 'checkbox'; 3599 checkbox.className = 'plate-enhancer-frame-checkbox'; 3600 checkbox.title = 'Include in re-processing'; 3601 thumb.appendChild(checkbox); 3602 } 3603 3604 const img = document.createElement('img'); 3605 img.src = frame.displayUrl || frame.dataUrl; // Show watermarked version 3606 thumb.appendChild(img); 3607 3608 const info = document.createElement('div'); 3609 info.className = 'plate-enhancer-thumbnail-info'; 3610 info.innerHTML = ` 3611 <div class="plate-enhancer-thumbnail-rank">${rank}</div> 3612 <div class="plate-enhancer-thumbnail-camera">${this.getCameraDisplayName(cameraLabel)}</div> 3613 `; 3614 thumb.appendChild(info); 3615 3616 return thumb; 3617 } 3618 3619 /** 3620 * Re-process with only selected frames 3621 * @param {number[]} selectedIndices - Indices of frames to include 3622 */ 3623 async reprocessWithSelectedFrames(selectedIndices) { 3624 // Use originalEnhanced if available (preserves all frames across reprocessing) 3625 const sourceEnhanced = this.originalEnhanced || this.lastEnhanced; 3626 3627 if (!this.lastExtraction || !sourceEnhanced) { 3628 this.showError('No extraction data available for re-processing'); 3629 return; 3630 } 3631 3632 this.isProcessing = true; 3633 this.shouldCancel = false; 3634 3635 // Show progress modal 3636 const backdrop = document.createElement('div'); 3637 backdrop.className = 'plate-enhancer-dialog-backdrop'; 3638 3639 const progress = document.createElement('div'); 3640 progress.className = 'plate-enhancer-progress'; 3641 progress.innerHTML = ` 3642 <h3>Re-processing with ${selectedIndices.length} frames</h3> 3643 <div class="plate-enhancer-progress-status">Preparing...</div> 3644 <div class="plate-enhancer-progress-bar"> 3645 <div class="plate-enhancer-progress-fill" style="width: 0%"></div> 3646 </div> 3647 <div class="plate-enhancer-progress-percent">0%</div> 3648 <div class="plate-enhancer-progress-cancel"> 3649 <button class="plate-enhancer-btn plate-enhancer-btn-secondary">Cancel</button> 3650 </div> 3651 `; 3652 3653 progress.querySelector('button').addEventListener('click', () => { 3654 this.shouldCancel = true; 3655 }); 3656 3657 document.body.appendChild(backdrop); 3658 document.body.appendChild(progress); 3659 3660 const updateProgress = (status, percent) => { 3661 progress.querySelector('.plate-enhancer-progress-status').textContent = status; 3662 progress.querySelector('.plate-enhancer-progress-fill').style.width = `${percent}%`; 3663 progress.querySelector('.plate-enhancer-progress-percent').textContent = `${Math.round(percent)}%`; 3664 }; 3665 3666 try { 3667 const { settings } = this.lastExtraction; 3668 3669 // Get only selected frames from the ORIGINAL topFrames (preserved across reprocessing) 3670 // This allows user to try different selections without losing frames 3671 const selectedFrameData = selectedIndices.map(i => sourceEnhanced.topFrames[i]); 3672 3673 // We need the original regions and frames that correspond to these 3674 // The topFrames have indices into the original data 3675 const selectedRegions = selectedFrameData.map(f => ({ 3676 x: 0, 3677 y: 0, 3678 width: f.width,
3679 height: f.height, 3680 imageData: f.imageData, 3681 sharpness: f.sharpness, 3682 cameraId: f.cameraId 3683 })); 3684 3685 // Create new stacker 3686 const stacker = new FrameStacker(); 3687 3688 if (settings.enableMLUpscale) { 3689 stacker.enableMLUpscale(true, settings.upscaleScale, 'thick'); 3690 stacker.postProcessSharpening = true; // Crisp text edges 3691 stacker.enablePreProcessing = true; // MAX QUALITY pipeline 3692 } 3693 3694 // ALWAYS use real multi-frame stacking 3695 stacker.useRealStacking = true; 3696 stacker.stackingMethod = 'sigma-mean'; 3697 3698 updateProgress('Processing selected frames...', 20); 3699 await new Promise(r => setTimeout(r, 47)); 3700 3701 if (this.shouldCancel) throw new Error('Cancelled'); 3702 3703 // Process with selected frames only 3704 // We pass the cropped regions directly since they're already extracted 3705 const enhanced = await stacker.processPreCropped(selectedFrameData, (status, pct) => { 3706 updateProgress(status, 20 + pct * 0.7); 3707 }, settings.videoEnhancements); 3708 3709 if (this.shouldCancel) throw new Error('Cancelled'); 3710 3711 // Store for potential further re-processing 3712 this.lastEnhanced = enhanced; 3713 3714 updateProgress('Complete!', 100); 3715 3716 backdrop.remove(); 3717 progress.remove(); 3718 3719 // Show results (with re-processing still available) 3720 await this.showResults(enhanced, true); 3721 3722 } catch (error) { 3723 backdrop.remove(); 3724 progress.remove(); 3725 3726 if (error.message !== 'Cancelled') { 3727 console.error('[PlateEnhancer] Re-processing error:', error); 3728 this.showError(`Re-processing failed: ${error.message}`); 3729 } 3730 } finally { 3731 this.isProcessing = false; 3732 } 3733 } 3734 3735 /** 3736 * Re-process all frames with a cropped region 3737 * @param {Object} selection - Crop selection with coordinates 3738 * @param {Object} enhanced - Current enhanced result with topFrames 3739 */ 3740 async reprocessWithCroppedRegion(selection, enhanced) { 3741 if (!enhanced || !enhanced.topFrames || enhanced.topFrames.length < 2) { 3742 this.showError('No frames available for re-processing'); 3743 return; 3744 } 3745 3746 this.isProcessing = true; 3747 this.shouldCancel = false; 3748 3749 // Show progress modal 3750 const backdrop = document.createElement('div'); 3751 backdrop.className = 'plate-enhancer-dialog-backdrop'; 3752 3753 const progress = document.createElement('div'); 3754 progress.className = 'plate-enhancer-progress'; 3755 progress.innerHTML = ` 3756 <h3>Re-processing with Cropped Region</h3> 3757 <div class="plate-enhancer-progress-status">Preparing...</div> 3758 <div class="plate-enhancer-progress-bar"> 3759 <div class="plate-enhancer-progress-fill" style="width: 0%"></div> 3760 </div> 3761 <div class="plate-enhancer-progress-percent">0%</div> 3762 <div class="plate-enhancer-progress-cancel"> 3763 <button class="plate-enhancer-btn plate-enhancer-btn-secondary">Cancel</button> 3764 </div> 3765 `; 3766 3767 progress.querySelector('button').addEventListener('click', () => { 3768 this.shouldCancel = true; 3769 }); 3770 3771 document.body.appendChild(backdrop); 3772 document.body.appendChild(progress); 3773 3774 const updateProgress = (status, percent) => { 3775 progress.querySelector('.plate-enhancer-progress-status').textContent = status; 3776 progress.querySelector('.plate-enhancer-progress-fill').style.width = `${percent}%`; 3777 progress.querySelector('.plate-enhancer-progress-percent').textContent = `${Math.round(percent)}%`; 3778 }; 3779 3780 try { 3781 // Calculate crop as percentages (relative to displayed image) 3782 const imgRect = selection.imgRect; 3783 const overlayRect = selection.overlayRect; 3784 3785 const imgOffsetX = imgRect.left - overlayRect.left; 3786 const imgOffsetY = imgRect.top - overlayRect.top; 3787 3788 const relX = selection.x - imgOffsetX; 3789 const relY = selection.y - imgOffsetY; 3790 3791 // Calculate crop percentages relative to displayed image 3792 const cropLeftPct = Math.max(0, relX / imgRect.width); 3793 const cropTopPct = Math.max(0, relY / imgRect.height); 3794 const cropWidthPct = Math.min(1 - cropLeftPct, selection.width / imgRect.width); 3795 const cropHeightPct = Math.min(1 - cropTopPct, selection.height / imgRect.height); 3796 3797 console.log('[PlateEnhancer] Crop percentages:', { cropLeftPct, cropTopPct, cropWidthPct, cropHeightPct }); 3798 3799 if (cropWidthPct <= 0 || cropHeightPct <= 0) { 3800 throw new Error('Invalid crop region'); 3801 } 3802 3803 // Apply crop to all source frames 3804 updateProgress('Cropping source frames...', 10); 3805 const croppedFrames = []; 3806 3807 for (let i = 0; i < enhanced.topFrames.length; i++) { 3808 if (this.shouldCancel) throw new Error('Cancelled'); 3809 3810 const frame = enhanced.topFrames[i]; 3811 const croppedDataUrl = await this.cropImageByPercentage(frame.dataUrl, cropLeftPct, cropTopPct, cropWidthPct, cropHeightPct); 3812 3813 croppedFrames.push({ 3814 dataUrl: croppedDataUrl, 3815 sharpness: frame.sharpness, 3816 cameraId: frame.cameraId, 3817 time: frame.time 3818 }); 3819 3820 updateProgress(`Cropping frames (${i + 1}/${enhanced.topFrames.length})...`, 10 + (i / enhanced.topFrames.length) * 20); 3821 } 3822 3823 if (this.shouldCancel) throw new Error('Cancelled'); 3824 3825 // Re-process cropped frames through stacker 3826 const settings = this.lastExtraction?.settings || {}; 3827 const stacker = new FrameStacker(); 3828 3829 if (settings.enableMLUpscale) { 3830 stacker.enableMLUpscale(true, settings.upscaleScale, 'thick'); 3831 stacker.postProcessSharpening = true; // Crisp text edges 3832 stacker.enablePreProcessing = true; // MAX QUALITY pipeline 3833 } 3834 3835 // ALWAYS use real multi-frame stacking 3836 stacker.useRealStacking = true; 3837 stacker.stackingMethod = 'sigma-mean'; 3838 3839 updateProgress('Stacking cropped frames...', 35); 3840
3841 const newEnhanced = await stacker.processPreCropped(croppedFrames, (status, pct) => { 3842 updateProgress(status, 35 + pct * 0.55); 3843 }, settings.videoEnhancements); 3844 3845 if (this.shouldCancel) throw new Error('Cancelled'); 3846 3847 // Store for potential further re-processing 3848 this.lastEnhanced = newEnhanced; 3849 3850 updateProgress('Complete!', 100); 3851 3852 backdrop.remove(); 3853 progress.remove(); 3854 3855 // Show results 3856 await this.showResults(newEnhanced, true); 3857 3858 } catch (error) { 3859 backdrop.remove(); 3860 progress.remove(); 3861 3862 if (error.message !== 'Cancelled') { 3863 console.error('[PlateEnhancer] Crop re-processing error:', error); 3864 this.showError(`Crop re-processing failed: ${error.message}`); 3865 } 3866 } finally { 3867 this.isProcessing = false; 3868 } 3869 } 3870 3871 /** 3872 * Crop an image by percentage values 3873 * @param {string} dataUrl - Source image 3874 * @param {number} leftPct - Left offset as percentage (0-1) 3875 * @param {number} topPct - Top offset as percentage (0-1) 3876 * @param {number} widthPct - Width as percentage (0-1) 3877 * @param {number} heightPct - Height as percentage (0-1) 3878 * @returns {string} Cropped image data URL 3879 */ 3880 async cropImageByPercentage(dataUrl, leftPct, topPct, widthPct, heightPct) { 3881 return new Promise((resolve, reject) => { 3882 const img = new Image(); 3883 img.onload = () => { 3884 const x = Math.round(img.naturalWidth * leftPct); 3885 const y = Math.round(img.naturalHeight * topPct); 3886 const w = Math.round(img.naturalWidth * widthPct); 3887 const h = Math.round(img.naturalHeight * heightPct); 3888 3889 const canvas = document.createElement('canvas'); 3890 canvas.width = w; 3891 canvas.height = h; 3892 const ctx = canvas.getContext('2d'); 3893 ctx.drawImage(img, x, y, w, h, 0, 0, w, h); 3894 3895 resolve(canvas.toDataURL('image/png')); 3896 }; 3897 img.onerror = reject; 3898 img.src = dataUrl; 3899 }); 3900 } 3901 3902 /** 3903 * Apply crop to result and add as new thumbnail 3904 * @param {string} imageSrc - Source image data URL 3905 * @param {Object} selection - Crop selection { x, y, width, height, overlayWidth, overlayHeight, imgRect, overlayRect } 3906 * @param {HTMLImageElement} mainImg - Main preview image element 3907 * @param {HTMLElement} mainLabel - Main preview label element 3908 * @param {HTMLElement} thumbsContainer - Thumbnails container 3909 */ 3910 async applyCropToResult(imageSrc, selection, mainImg, mainLabel, thumbsContainer) { 3911 try { 3912 // Load the image to get natural dimensions 3913 const img = new Image(); 3914 await new Promise((resolve, reject) => { 3915 img.onload = resolve; 3916 img.onerror = reject; 3917 img.src = imageSrc; 3918 }); 3919 3920 // The image might be letterboxed within the container due to object-fit: contain 3921 // We need to calculate where the image actually is displayed 3922 const imgRect = selection.imgRect; 3923 const overlayRect = selection.overlayRect; 3924 3925 // Calculate the offset of the image within the overlay 3926 const imgOffsetX = imgRect.left - overlayRect.left; 3927 const imgOffsetY = imgRect.top - overlayRect.top; 3928 3929 // Adjust selection coordinates to be relative to the displayed image 3930 const relX = selection.x - imgOffsetX; 3931 const relY = selection.y - imgOffsetY; 3932 3933 // Scale from displayed image size to natural image size 3934 const scaleX = img.naturalWidth / imgRect.width; 3935 const scaleY = img.naturalHeight / imgRect.height; 3936 3937 // Calculate crop in natural pixel coordinates 3938 const cropX = Math.max(0, Math.round(relX * scaleX)); 3939 const cropY = Math.max(0, Math.round(relY * scaleY)); 3940 let cropWidth = Math.round(selection.width * scaleX); 3941 let cropHeight = Math.round(selection.height * scaleY); 3942 3943 // Clamp to image bounds
3944 cropWidth = Math.min(cropWidth, img.naturalWidth - cropX); 3945 cropHeight = Math.min(cropHeight, img.naturalHeight - cropY); 3946 3947 console.log('[PlateEnhancer] Crop coords:', { cropX, cropY, cropWidth, cropHeight, naturalW: img.naturalWidth, naturalH: img.naturalHeight }); 3948 3949 // Validate crop dimensions 3950 if (cropWidth <= 0 || cropHeight <= 0) { 3951 this.showToast('Invalid crop region - selection outside image'); 3952 return; 3953 } 3954 3955 // Create canvas and crop 3956 const canvas = document.createElement('canvas'); 3957 canvas.width = cropWidth; 3958 canvas.height = cropHeight; 3959 const ctx = canvas.getContext('2d'); 3960 3961 ctx.drawImage(img, cropX, cropY, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight); 3962 3963 const croppedDataUrl = canvas.toDataURL('image/png'); 3964 3965 // Update main preview 3966 mainImg.src = croppedDataUrl; 3967 mainLabel.textContent = 'Cropped Result'; 3968 3969 // Add cropped version as a new thumbnail at the beginning 3970 const croppedThumb = document.createElement('div'); 3971 croppedThumb.className = 'plate-enhancer-thumbnail cropped selected'; 3972 croppedThumb.dataset.src = croppedDataUrl; 3973 croppedThumb.dataset.label = 'Cropped Result'; 3974 3975 const thumbImg = document.createElement('img'); 3976 thumbImg.src = croppedDataUrl; 3977 croppedThumb.appendChild(thumbImg); 3978 3979 const info = document.createElement('div'); 3980 info.className = 'plate-enhancer-thumbnail-info'; 3981 info.innerHTML = ` 3982 <div class="plate-enhancer-thumbnail-rank">Cropped</div> 3983 <div class="plate-enhancer-thumbnail-camera">${cropWidth}x${cropHeight}</div> 3984 `; 3985 croppedThumb.appendChild(info); 3986 3987 // Deselect other thumbnails 3988 thumbsContainer.querySelectorAll('.plate-enhancer-thumbnail').forEach(t => t.classList.remove('selected')); 3989 3990 // Insert at beginning (after combined if it exists) 3991 const combined = thumbsContainer.querySelector('.combined'); 3992 if (combined) { 3993 combined.after(croppedThumb); 3994 } else { 3995 thumbsContainer.prepend(croppedThumb); 3996 } 3997 3998 this.showToast('Region cropped successfully!'); 3999 4000 } catch (error) { 4001 console.error('[PlateEnhancer] Crop failed:', error); 4002 this.showToast('Crop failed'); 4003 } 4004 } 4005 4006 /** 4007 * Check if user has Pro access (no watermark needed) 4008 * @returns {Promise<boolean>} 4009 */ 4010 async isProUser() { 4011 try { 4012 const access = await window.app?.sessionManager?.checkAccess?.('plateEnhancement'); 4013 return access?.allowed === true; 4014 } catch (e) { 4015 return false; 4016 } 4017 } 4018 4019 /** 4020 * Add watermark to image for free users 4021 * @param {string} dataUrl - Source image data URL 4022 * @returns {Promise<string>} - Watermarked image data URL 4023 */ 4024 async addWatermark(dataUrl) { 4025 return new Promise((resolve) => { 4026 const img = new Image(); 4027 img.onload = () => { 4028 const canvas = document.createElement('canvas'); 4029 canvas.width = img.width; 4030 canvas.height = img.height; 4031 const ctx = canvas.getContext('2d'); 4032 4033 // Draw original image 4034 ctx.drawImage(img, 0, 0); 4035 4036 // Calculate diagonal angle from corner to corner 4037 const diagonalAngle = Math.atan2(img.height, img.width); 4038 const diagonalLength = Math.sqrt(img.width * img.width + img.height * img.height); 4039 4040 // Font size based on diagonal length for good coverage 4041 const fontSize = Math.max(16, Math.min(48, diagonalLength / 12)); 4042 ctx.font = `bold ${fontSize}px Arial, sans-serif`; 4043 4044 const text = 'TeslaCamViewer.com'; 4045 const textMetrics = ctx.measureText(text); 4046 4047 // Save context, move to center, rotate 4048 ctx.save(); 4049 ctx.translate(img.width / 2, img.height / 2); 4050 ctx.rotate(diagonalAngle); 4051 4052 // Draw text centered on diagonal with outline for visibility 4053 ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'; 4054 ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)'; 4055 ctx.lineWidth = 3; 4056 ctx.textAlign = 'center'; 4057 ctx.textBaseline = 'middle'; 4058
4059 ctx.strokeText(text, 0, 0); 4060 ctx.fillText(text, 0, 0); 4061 4062 ctx.restore(); 4063 4064 resolve(canvas.toDataURL('image/png')); 4065 }; 4066 img.onerror = () => resolve(dataUrl); // Fallback to original on error 4067 img.src = dataUrl; 4068 }); 4069 } 4070 4071 /** 4072 * Download single image (with watermark for free users) 4073 */ 4074 async downloadImage(dataUrl, filename) { 4075 // Check if Pro user - Pro users get clean images 4076 const isPro = await this.isProUser(); 4077 const finalDataUrl = isPro ? dataUrl : await this.addWatermark(dataUrl); 4078 4079 const link = document.createElement('a'); 4080 link.href = finalDataUrl; 4081 link.download = filename; 4082 document.body.appendChild(link); 4083 link.click(); 4084 document.body.removeChild(link); 4085 } 4086 4087 /** 4088 * Copy image to clipboard (with watermark for free users) 4089 */ 4090 async copyToClipboard(dataUrl) { 4091 try { 4092 // Check if Pro user - Pro users get clean images 4093 const isPro = await this.isProUser(); 4094 const finalDataUrl = isPro ? dataUrl : await this.addWatermark(dataUrl); 4095 4096 // Convert data URL to blob 4097 const response = await fetch(finalDataUrl); 4098 const blob = await response.blob(); 4099 4100 // Use clipboard API 4101 await navigator.clipboard.write([ 4102 new ClipboardItem({ 4103 [blob.type]: blob 4104 }) 4105 ]); 4106 4107 // Show brief success feedback 4108 this.showToast('Copied to clipboard!'); 4109 } catch (error) { 4110 console.error('[PlateEnhancer] Copy failed:', error); 4111 this.showError('Failed to copy to clipboard. Try downloading instead.'); 4112 } 4113 } 4114 4115 /** 4116 * Show a brief toast notification 4117 */ 4118 showToast(message) { 4119 const toast = document.createElement('div'); 4120 toast.className = 'plate-enhancer-toast'; 4121 toast.textContent = message; 4122 document.body.appendChild(toast); 4123 4124 // Animate in 4125 setTimeout(() => toast.classList.add('show'), 10); 4126 4127 // Remove after delay 4128 setTimeout(() => { 4129 toast.classList.remove('show'); 4130 setTimeout(() => toast.remove(), 300); 4131 }, 2000); 4132 } 4133 4134 /** 4135 * Download all results as ZIP (with watermarks for free users) 4136 */ 4137 async downloadAllAsZip(enhanced) { 4138 if (typeof JSZip === 'undefined') { 4139 this.showError('ZIP library not available'); 4140 return; 4141 } 4142 4143 // Check if Pro user - Pro users get clean images 4144 const isPro = await this.isProUser(); 4145 4146 const zip = new JSZip(); 4147 const folder = zip.folder('enhanced-plates'); 4148 4149 // Helper to get data for zip (with optional watermark) 4150 const getImageData = async (dataUrl) => { 4151 if (isPro) { 4152 return dataUrl.split(',')[1]; 4153 } 4154 const watermarked = await this.addWatermark(dataUrl); 4155 return watermarked.split(',')[1]; 4156 }; 4157 4158 // Add combined result 4159 const combinedData = await getImageData(enhanced.combined.dataUrl); 4160 folder.file('00-combined.png', combinedData, { base64: true }); 4161 4162 // Add upscaled version if available 4163 if (enhanced.upscaled) { 4164 const upscaledData = await getImageData(enhanced.upscaled.dataUrl); 4165 const scale = enhanced.upscaled.scale || 2; 4166 folder.file(`00-combined-${scale}x-upscaled.png`, upscaledData, { base64: true }); 4167 } 4168 4169 // Add all enhancement methods (including Lucky Regions if available) 4170 if (enhanced.methods) { 4171 const methodOrder = ['sigmaClipped', 'msrClahe', 'weightedMean', 'bestFrame', 'bilateral', 'ensemble', 'luckyRegions']; 4172 for (let idx = 0; idx < methodOrder.length; idx++) { 4173 const key = methodOrder[idx]; 4174 const method = enhanced.methods[key]; 4175 if (method?.dataUrl) { 4176 // Add original version 4177 const data = await getImageData(method.dataUrl); 4178 folder.file(`01-method-${String(idx + 1).padStart(2, '0')}-${key}.png`, data, { base64: true }); 4179 4180 // Add 4x upscaled version if available 4181 if (method.upscaledDataUrl) { 4182 const upscaledData = await getImageData(method.upscaledDataUrl); 4183 folder.file(`01-method-${String(idx + 1).padStart(2, '0')}-${key}-4x.png`, upscaledData, { base64: true }); 4184 } 4185 } 4186 } 4187 } 4188 4189 // Add top frames 4190 for (let idx = 0; idx < enhanced.topFrames.length; idx++) { 4191 const frame = enhanced.topFrames[idx]; 4192 const data = await getImageData(frame.dataUrl); 4193 const camera = frame.cameraId.replace('_', '-'); 4194 folder.file(`02-source-${String(idx + 1).padStart(2, '0')}-${camera}.png`, data, { base64: true }); 4195 } 4196 4197 // Generate and download 4198 const blob = await zip.generateAsync({ type: 'blob' }); 4199 const url = URL.createObjectURL(blob); 4200 const link = document.createElement('a'); 4201 link.href = url; 4202 link.download = 'enhanced-plates.zip'; 4203 document.body.appendChild(link); 4204 link.click(); 4205 document.body.removeChild(link); 4206 URL.revokeObjectURL(url); 4207 } 4208 4209 /** 4210 * Get current video enhancement settings (brightness, contrast, saturation) 4211 */ 4212 getVideoEnhancementSettings() { 4213 const enhancer = window.app?.videoEnhancer;
4214 if (!enhancer || !enhancer.settings) { 4215 return { brightness: 100, contrast: 100, saturation: 100 }; 4216 } 4217 // Use ?? instead of || to handle 0 values correctly (saturation=0 means grayscale) 4218 return { 4219 brightness: enhancer.settings.brightness ?? 100, 4220 contrast: enhancer.settings.contrast ?? 100, 4221 saturation: enhancer.settings.saturation ?? 100 4222 }; 4223 } 4224 4225 /** 4226 * Apply video enhancement settings to a canvas context before drawing 4227 * Uses CSS filter syntax on canvas 2D context 4228 * @param {CanvasRenderingContext2D} ctx - The canvas context 4229 * @param {Object} settings - Video enhancement settings { brightness, contrast, saturation } 4230 * @returns {string|null} The filter string applied, or null if no filter needed 4231 */ 4232 applyVideoEnhancementsToCanvas(ctx, settings) { 4233 if (!settings) return null; 4234 4235 const { brightness, contrast, saturation } = settings; 4236 4237 // Skip if all settings are at default (100) 4238 if (brightness === 100 && contrast === 100 && saturation === 100) { 4239 return null; 4240 } 4241 4242 // Build CSS filter string (values are 0-200, where 100 = 1.0) 4243 const filterParts = []; 4244 if (brightness !== 100) { 4245 filterParts.push(`brightness(${brightness / 100})`); 4246 } 4247 if (contrast !== 100) { 4248 filterParts.push(`contrast(${contrast / 100})`); 4249 } 4250 if (saturation !== 100) { 4251 filterParts.push(`saturate(${saturation / 100})`); 4252 } 4253 4254 const filterString = filterParts.join(' '); 4255 ctx.filter = filterString; 4256 4257 return filterString; 4258 } 4259 4260 /** 4261 * Show error message 4262 */ 4263 showError(message) { 4264 const backdrop = document.createElement('div'); 4265 backdrop.className = 'plate-enhancer-dialog-backdrop'; 4266 4267 const dialog = document.createElement('div'); 4268 dialog.className = 'plate-enhancer-dialog'; 4269 dialog.innerHTML = ` 4270 <h3>Error</h3> 4271 <div class="plate-enhancer-error">${message}</div> 4272 <div class="plate-enhancer-dialog-actions"> 4273 <button class="plate-enhancer-btn plate-enhancer-btn-primary">OK</button> 4274 </div> 4275 `; 4276 4277 dialog.querySelector('button').addEventListener('click', () => { 4278 backdrop.remove(); 4279 dialog.remove(); 4280 }); 4281 4282 backdrop.addEventListener('click', () => { 4283 backdrop.remove(); 4284 dialog.remove(); 4285 }); 4286 4287 document.body.appendChild(backdrop); 4288 document.body.appendChild(dialog); 4289 } 4290 4291 /** 4292 * Load Tesseract.js on demand 4293 */ 4294 async loadTesseract() { 4295 if (this.isTesseractLoaded) return true; 4296 if (this.isLoadingTesseract) { 4297 // Wait for existing load 4298 return new Promise((resolve) => { 4299 const check = setInterval(() => { 4300 if (this.isTesseractLoaded) { 4301 clearInterval(check); 4302 resolve(true); 4303 } 4304 }, 100); 4305 setTimeout(() => { 4306 clearInterval(check); 4307 resolve(false); 4308 }, 30000); 4309 }); 4310 } 4311 4312 this.isLoadingTesseract = true; 4313 4314 try { 4315 // Load Tesseract.js from CDN 4316 await new Promise((resolve, reject) => { 4317 const script = document.createElement('script'); 4318 script.src = 'https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js'; 4319 script.onload = resolve; 4320 script.onerror = reject; 4321 document.head.appendChild(script); 4322 }); 4323 4324 this.isTesseractLoaded = true; 4325 console.log('[PlateEnhancer] Tesseract.js loaded'); 4326 return true; 4327 } catch (error) { 4328 console.error('[PlateEnhancer] Failed to load Tesseract.js:', error); 4329 return false; 4330 } finally { 4331 this.isLoadingTesseract = false; 4332 } 4333 } 4334 4335 /** 4336 * Check Pro access for a feature 4337 * @param {string} feature - Feature name for display 4338 * @returns {boolean} - Whether access is allowed 4339 */ 4340 async checkProAccess(feature) { 4341 // Check if session manager exists and has checkAccess 4342 if (!window.app?.sessionManager?.checkAccess) { 4343 // No session manager - this is free mode, block Pro features 4344 console.log(`[PlateEnhancer] Pro feature "${feature}" blocked - no session manager`); 4345 this.showToast('This feature requires TeslaCamViewer Pro'); 4346 return false;
4347 } 4348 4349 const access = await window.app.sessionManager.checkAccess('plateEnhancement'); 4350 if (!access.allowed) { 4351 window.app.sessionManager.showLimitModal('premium'); 4352 return false; 4353 } 4354 return true; 4355 } 4356 4357 /** 4358 * Run ensemble OCR across all enhancement methods 4359 * Takes the best result from all methods 4360 * @param {Object} enhanced - Enhanced results with methods 4361 * @param {Function} onProgress - Progress callback 4362 * @returns {Object} - { text, confidence, warning } 4363 */ 4364 async runEnsembleOCR(enhanced, onProgress = null) { 4365 // Collect all images to run OCR on 4366 const images = []; 4367 4368 // Add the main combined result 4369 images.push({ name: 'combined', dataUrl: enhanced.combined.dataUrl }); 4370 4371 // Add all method results if available 4372 if (enhanced.methods) { 4373 for (const [key, method] of Object.entries(enhanced.methods)) { 4374 if (method.dataUrl) { 4375 images.push({ name: key, dataUrl: method.dataUrl }); 4376 } 4377 } 4378 } 4379 4380 // Add upscaled if available 4381 if (enhanced.upscaled?.dataUrl) { 4382 images.push({ name: 'upscaled', dataUrl: enhanced.upscaled.dataUrl }); 4383 } 4384 4385 if (onProgress) onProgress('Running ensemble OCR...', 0); 4386 4387 const results = []; 4388 const methodResults = {}; // Store per-method OCR results 4389 let processedCount = 0; 4390 4391 for (const img of images) { 4392 try { 4393 const result = await this.runOCR(img.dataUrl, (status, progress) => { 4394 if (onProgress) { 4395 const overallProgress = (processedCount + (progress || 0)) / images.length; 4396 onProgress(`Analyzing ${img.name}...`, overallProgress); 4397 } 4398 }); 4399 4400 // Store per-method result 4401 methodResults[img.name] = { 4402 text: result.text || '', 4403 confidence: result.confidence || 0 4404 }; 4405 4406 if (result.text && result.confidence > 0) { 4407 results.push({ 4408 ...result, 4409 source: img.name 4410 }); 4411 } 4412 } catch (e) { 4413 console.warn(`[PlateEnhancer] OCR failed for ${img.name}:`, e); 4414 methodResults[img.name] = { text: '', confidence: 0, error: e.message }; 4415 } 4416 processedCount++; 4417 } 4418 4419 if (results.length === 0) { 4420 return { text: '', confidence: 0, error: 'No text detected', methodResults }; 4421 } 4422 4423 // Vote for the best result 4424 // Group by text and sum confidences 4425 const textVotes = new Map(); 4426 for (const result of results) { 4427 const normalizedText = result.text.toUpperCase().replace(/\s+/g, ''); 4428 if (!textVotes.has(normalizedText)) { 4429 textVotes.set(normalizedText, { text: result.text, totalConf: 0, count: 0 }); 4430 } 4431 const vote = textVotes.get(normalizedText); 4432 vote.totalConf += result.confidence; 4433 vote.count++; 4434 } 4435 4436 // Find best result (highest total confidence) 4437 let bestResult = null; 4438 let bestScore = -1;
4439 for (const vote of textVotes.values()) { 4440 const score = vote.totalConf + (vote.count * 10); // Bonus for consistency 4441 if (score > bestScore) { 4442 bestScore = score; 4443 bestResult = vote; 4444 } 4445 } 4446 4447 const avgConfidence = bestResult.totalConf / bestResult.count; 4448 console.log(`[PlateEnhancer] Ensemble OCR: "${bestResult.text}" (${avgConfidence.toFixed(1)}% avg, ${bestResult.count} votes)`); 4449 4450 return { 4451 text: bestResult.text, 4452 confidence: avgConfidence, 4453 votes: bestResult.count, 4454 warning: avgConfidence < 50, 4455 methodResults // Include per-method results 4456 }; 4457 } 4458 4459 /** 4460 * Run OCR on an image using PlateRecognizer (CCT model) 4461 * @param {string} dataUrl - Image data URL 4462 * @param {Function} onProgress - Progress callback (status, progress 0-1) 4463 * @returns {Object} - { text, confidence } 4464 */ 4465 async runOCR(dataUrl, onProgress = null) { 4466 // Use PlateRecognizer (CCT model trained on license plates) for better accuracy 4467 if (!window.plateRecognizer) { 4468 window.plateRecognizer = new PlateRecognizer(); 4469 } 4470 4471 const recognizer = window.plateRecognizer; 4472 4473 // Load the model if needed 4474 if (!recognizer.isLoaded) { 4475 if (onProgress) onProgress('Loading plate OCR model...', null); 4476 const loaded = await recognizer.loadModel((progress) => { 4477 if (onProgress && progress.percent >= 0) { 4478 onProgress(progress.status || 'Loading...', progress.percent / 100); 4479 } 4480 }); 4481 if (!loaded) { 4482 console.warn('[PlateEnhancer] PlateRecognizer failed to load, falling back to Tesseract'); 4483 return this.runOCRTesseract(dataUrl, onProgress); 4484 } 4485 } 4486 4487 try { 4488 if (onProgress) onProgress('Recognizing plate...', 0.5); 4489 4490 // Convert dataUrl to image 4491 const img = new Image(); 4492 await new Promise((resolve, reject) => { 4493 img.onload = resolve; 4494 img.onerror = reject; 4495 img.src = dataUrl; 4496 }); 4497 4498 // Use ensemble recognition for best results 4499 const result = await recognizer.recognizeEnsemble(img, {}); 4500 4501 console.log(`[PlateEnhancer] OCR result: "${result.text}" (confidence: ${result.confidence.toFixed(1)}%)`); 4502 4503 return { 4504 text: result.text, 4505 confidence: result.confidence 4506 }; 4507 } catch (error) { 4508 console.error('[PlateEnhancer] PlateRecognizer OCR failed:', error); 4509 // Fallback to Tesseract 4510 console.log('[PlateEnhancer] Falling back to Tesseract OCR...'); 4511 return this.runOCRTesseract(dataUrl, onProgress); 4512 } 4513 } 4514 4515 /** 4516 * Fallback OCR using Tesseract 4517 * @param {string} dataUrl - Image data URL 4518 * @param {Function} onProgress - Progress callback 4519 * @returns {Object} - { text, confidence } 4520 */ 4521 async runOCRTesseract(dataUrl, onProgress = null) { 4522 if (!this.isTesseractLoaded) { 4523 if (onProgress) onProgress('Loading Tesseract...', null); 4524 const loaded = await this.loadTesseract(); 4525 if (!loaded) { 4526 return { text: '', confidence: 0, error: 'Failed to load OCR library' }; 4527 } 4528 } 4529 4530 try { 4531 const result = await Tesseract.recognize(dataUrl, 'eng', { 4532 logger: m => { 4533 if (onProgress && m.progress) { 4534 onProgress('Analyzing text...', m.progress); 4535 } 4536 } 4537 }); 4538 4539 const rawText = result.data.text.trim(); 4540 const cleanedText = rawText.replace(/[^A-Z0-9\s-]/gi, '').trim(); 4541 4542 return { 4543 text: cleanedText, 4544 rawText: rawText, 4545 confidence: result.data.confidence 4546 }; 4547 } catch (error) { 4548 console.error('[PlateEnhancer] Tesseract OCR failed:', error); 4549 return { text: '', confidence: 0, error: error.message }; 4550 } 4551 } 4552} 4553 4554// Create global instance 4555window.plateEnhancer = new PlateEnhancer();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.