package main import ( "fmt" "image" "image/color" "io" "os" "strings" "time" "gocv.io/x/gocv" ) func loadTemplates() (map[int][]gocv.Mat, error) { templates := make(map[int][]gocv.Mat) files, err := os.ReadDir("training_digits") if err != nil { logMessage(Console, Warning, "⚠️ WARNING: training_digits directory not found or cannot be read: %v", err) logMessage(Console, Warning, "⚠️ Starting with empty template set. Recognition will not work until templates are added.") logMessage(Console, Info, "") return templates, nil } templateCount := 0 for _, file := range files { if !strings.HasSuffix(file.Name(), ".png") { continue } name := strings.TrimSuffix(file.Name(), ".png") parts := strings.Split(name, "_") var digit int _, err := fmt.Sscanf(parts[0], "%d", &digit) if err != nil || digit < 0 || digit > 9 { continue } filename := fmt.Sprintf("training_digits/%s", file.Name()) template := gocv.IMRead(filename, gocv.IMReadGrayScale) if template.Empty() { logMessage(Console, Warning, "Warning: Failed to load %s", filename) continue } templates[digit] = append(templates[digit], template) templateCount++ } // Load invalid digit markers (use index -1 for invalid patterns) invalidFiles, err := os.ReadDir("training_digits/invalid") if err == nil { invalidCount := 0 for _, file := range invalidFiles { if !strings.HasSuffix(file.Name(), ".png") { continue } filename := fmt.Sprintf("training_digits/invalid/%s", file.Name()) template := gocv.IMRead(filename, gocv.IMReadGrayScale) if template.Empty() { logMessage(Console, Warning, "Warning: Failed to load invalid template %s", filename) continue } templates[-1] = append(templates[-1], template) invalidCount++ } if invalidCount > 0 { logMessage(Console, Info, "✓ Loaded %d invalid digit markers", invalidCount) } } for digit := 0; digit <= 9; digit++ { if len(templates[digit]) == 0 { logMessage(Console, Warning, "⚠️ WARNING: No templates found for digit %d", digit) } } logMessage(Console, Info, "✓ Loaded %d digit templates (0-9)", templateCount) return templates, nil } func matchDigit(digitImg gocv.Mat, templates map[int][]gocv.Mat) (int, float64) { bestDigit := -1 bestScore := 0.0 bestInvalidScore := 0.0 // Check invalid patterns first (stored at index -1) invalidTemplates := templates[-1] for _, template := range invalidTemplates { compareImg := digitImg if digitImg.Rows() != template.Rows() || digitImg.Cols() != template.Cols() { resized := gocv.NewMat() gocv.Resize(digitImg, &resized, image.Pt(template.Cols(), template.Rows()), 0, 0, gocv.InterpolationLinear) defer resized.Close() compareImg = resized } diff := gocv.NewMat() gocv.AbsDiff(compareImg, template, &diff) totalPixels := compareImg.Rows() * compareImg.Cols() diffPixels := gocv.CountNonZero(diff) samePixels := totalPixels - diffPixels score := (float64(samePixels) / float64(totalPixels)) * 100.0 diff.Close() if score > bestInvalidScore { bestInvalidScore = score } } // Check regular digits (0-9) for digit := 0; digit <= 9; digit++ { digitTemplates := templates[digit] if len(digitTemplates) == 0 { // No templates for this digit, skip continue } for _, template := range digitTemplates { compareImg := digitImg if digitImg.Rows() != template.Rows() || digitImg.Cols() != template.Cols() { resized := gocv.NewMat() gocv.Resize(digitImg, &resized, image.Pt(template.Cols(), template.Rows()), 0, 0, gocv.InterpolationLinear) defer resized.Close() compareImg = resized } diff := gocv.NewMat() gocv.AbsDiff(compareImg, template, &diff) totalPixels := compareImg.Rows() * compareImg.Cols() diffPixels := gocv.CountNonZero(diff) samePixels := totalPixels - diffPixels score := (float64(samePixels) / float64(totalPixels)) * 100.0 diff.Close() if score > bestScore { bestScore = score bestDigit = digit } } } // If invalid pattern matches better than any digit (and score > 70%), return -1 if bestInvalidScore > bestScore && bestInvalidScore > 70 { return -1, bestInvalidScore } return bestDigit, bestScore } // Helper function: check if there's a '1' digit by cutting at the expected width and matching templates // ONLY checks against '1' templates to prevent false matches with '4', '7', etc. func hasOneAt(thresh gocv.Mat, x int, templates map[int][]gocv.Mat, logger io.Writer) bool { h := thresh.Rows() w := thresh.Cols() // Calculate the region where a '1' would be if it exists // '1' is 72px wide, so extract from (x - 72) to x leftEdge := x - DIGIT_ONE_WIDTH if leftEdge < 0 { leftEdge = 0 } if x > w { x = w } logMessage(Both, Debug, " hasOneAt(x=%d): extracting region [%d..%d] (width=%d) from display width=%d", x, leftEdge, x, x-leftEdge, w) // Extract the potential '1' region region := thresh.Region(image.Rect(leftEdge, 0, x, h)) digitImg := region.Clone() region.Close() // Match ONLY against '1' templates (don't check other digits) oneTemplates := templates[1] if len(oneTemplates) == 0 { logMessage(LogFile, Warning, " hasOneAt(x=%d): No '1' templates loaded -> NO", x) digitImg.Close() return false } bestScore := 0.0 for _, template := range oneTemplates { compareImg := digitImg if digitImg.Rows() != template.Rows() || digitImg.Cols() != template.Cols() { resized := gocv.NewMat() gocv.Resize(digitImg, &resized, image.Pt(template.Cols(), template.Rows()), 0, 0, gocv.InterpolationLinear) defer resized.Close() compareImg = resized } diff := gocv.NewMat() gocv.AbsDiff(compareImg, template, &diff) totalPixels := compareImg.Rows() * compareImg.Cols() diffPixels := gocv.CountNonZero(diff) samePixels := totalPixels - diffPixels score := (float64(samePixels) / float64(totalPixels)) * 100.0 diff.Close() if score > bestScore { bestScore = score } } digitImg.Close() // If it matches '1' with >85% confidence, we have a '1' if bestScore > 85.0 { logMessage(Both, Debug, " hasOneAt(x=%d): matched '1' with %.1f%% confidence -> YES", x, bestScore) return true } logMessage(Both, Debug, " hasOneAt(x=%d): best '1' match %.1f%% confidence -> NO", x, bestScore) return false } // validateEight checks if a digit image has the two characteristic holes of an '8' // by checking for black pixels along horizontal lines where holes should be func validateEight(digitImg gocv.Mat, logger io.Writer) bool { h := digitImg.Rows() w := digitImg.Cols() // Upper hole: 30% of height // Lower hole: 70% of height // Horizontal range: 40%-50% of width topHoleY := (h * 30) / 100 bottomHoleY := (h * 70) / 100 xStart := (w * 40) / 100 xEnd := (w * 50) / 100 // Check for ANY black pixel in the horizontal range // In thresholded image: black (0) = hole, white (255) = digit topHoleFound := false for x := xStart; x <= xEnd; x++ { pixel := digitImg.GetUCharAt(topHoleY, x) if pixel < 128 { topHoleFound = true break } } bottomHoleFound := false for x := xStart; x <= xEnd; x++ { pixel := digitImg.GetUCharAt(bottomHoleY, x) if pixel < 128 { bottomHoleFound = true break } } logMessage(LogFile, Debug, " validateEight: top hole @ y=%d (30%%) x=%d-%d (%v), bottom hole @ y=%d (70%%) x=%d-%d (%v)", topHoleY, xStart, xEnd, topHoleFound, bottomHoleY, xStart, xEnd, bottomHoleFound) return topHoleFound && bottomHoleFound } // validateZero checks if a digit image has the characteristic hole of a '0' // by checking for black pixels along a horizontal line at the center func validateZero(digitImg gocv.Mat, logger io.Writer) bool { h := digitImg.Rows() w := digitImg.Cols() // Center hole: 50% of height // Horizontal range: 30%-70% of width (wider range than '8' since '0' hole is larger) centerY := h / 2 xStart := (w * 30) / 100 xEnd := (w * 70) / 100 // Check for ANY black pixel in the horizontal range // In thresholded image: black (0) = hole, white (255) = digit holeFound := false for x := xStart; x <= xEnd; x++ { pixel := digitImg.GetUCharAt(centerY, x) if pixel < 128 { holeFound = true break } } logMessage(LogFile, Debug, " validateZero: center hole @ y=%d (50%%) x=%d-%d (%v)", centerY, xStart, xEnd, holeFound) return holeFound } // decolorizeAlarmMode converts colored alarm backgrounds (yellow/orange, cyan/blue) to black // while preserving white digits, so normal OCR thresholding works correctly func decolorizeAlarmMode(displayImg gocv.Mat) gocv.Mat { // Create output image decolorized := gocv.NewMatWithSize(displayImg.Rows(), displayImg.Cols(), displayImg.Type()) // Process each pixel for y := 0; y < displayImg.Rows(); y++ { for x := 0; x < displayImg.Cols(); x++ { // Get BGR values b := displayImg.GetUCharAt(y, x*3) // Blue g := displayImg.GetUCharAt(y, x*3+1) // Green r := displayImg.GetUCharAt(y, x*3+2) // Red // If pixel is "white-ish" (all channels > 240), keep it white // Otherwise (colored background, borders, etc.), set to black if r > 240 && g > 240 && b > 240 { // White digit - keep as white decolorized.SetUCharAt(y, x*3, 255) decolorized.SetUCharAt(y, x*3+1, 255) decolorized.SetUCharAt(y, x*3+2, 255) } else { // Colored background or dark area - set to black decolorized.SetUCharAt(y, x*3, 0) decolorized.SetUCharAt(y, x*3+1, 0) decolorized.SetUCharAt(y, x*3+2, 0) } } } return decolorized } // New simplified version that works with fixed-width rectangles func recognizeDisplayArea(rotated gocv.Mat, area image.Rectangle, templates map[int][]gocv.Mat, displayName string, frameCount int, logger io.Writer) (int, int, float64, int, float64) { startTime := time.Now() cleanName := strings.TrimSpace(strings.ToLower(displayName)) // Extract the display area (ALREADY THRESHOLDED BINARY IMAGE) region := rotated.Region(area) thresh := region.Clone() region.Close() // DEBUG: Save extracted region - already thresholded if DEBUG_MODE { step1Filename := fmt.Sprintf("review/f%d_%s_step1_extracted.png", frameCount, cleanName) gocv.IMWrite(step1Filename, thresh) } w := thresh.Cols() h := thresh.Rows() // Log display dimensions logMessage(LogFile, Debug, " %s display: w=%d, h=%d", displayName, w, h) // Sequential check for '1' patterns to determine digit widths // hasOneAt checks if column X is white and column X+10 is black (detects RIGHT EDGE where '1' ends) // Digit detection (right to left: digit3, digit2, digit1) // D stores cut positions, digitIsOne stores detection results D := make([]int, 4) // indices 1,2,3 (0 unused) digitIsOne := make([]bool, 4) // indices 1,2,3 (0 unused) totalWidth := 0 logMessage(Both, Debug, " [%s] Display width=%d, starting digit detection...", displayName, w) detectionStart := time.Now() for i := 3; i >= 1; i-- { var checkPos int if i == 3 { checkPos = w // Check at right edge, not w-8 } else { checkPos = w - totalWidth - 5 } logMessage(Both, Debug, " [%s] Digit %d: checkPos=%d (w=%d, totalWidth=%d)", displayName, i, checkPos, w, totalWidth) digitIsOne[i] = hasOneAt(thresh, checkPos, templates, logger) // Add width to accumulator var widthUsed int if digitIsOne[i] { widthUsed = DIGIT_ONE_WIDTH totalWidth += DIGIT_ONE_WIDTH logMessage(Both, Debug, " [%s] Digit %d: IS ONE -> using width=%d (totalWidth now %d)", displayName, i, widthUsed, totalWidth) } else { widthUsed = DIGIT_NON_ONE_WIDTH totalWidth += DIGIT_NON_ONE_WIDTH logMessage(Both, Debug, " [%s] Digit %d: NOT ONE -> using width=%d (totalWidth now %d)", displayName, i, widthUsed, totalWidth) } D[i] = w - totalWidth logMessage(Both, Debug, " [%s] Digit %d: CUT position D[%d]=%d", displayName, i, i, D[i]) } logMessage(LogFile, Debug, " [TIMING][%s] Digit detection (hasOneAt x3): %dms", displayName, time.Since(detectionStart).Milliseconds()) logMessage(Both, Debug, " [%s] Final cut positions: D[1]=%d, D[2]=%d, D[3]=%d", displayName, D[1], D[2], D[3]) // Create visualization image showing check positions AND cut positions visImg := gocv.NewMat() gocv.CvtColor(thresh, &visImg, gocv.ColorGrayToBGR) // Draw vertical lines at check positions with labels greenColor := color.RGBA{0, 255, 0, 255} // Green for '1' detected redColor := color.RGBA{0, 0, 255, 255} // Red for no '1' yellowColor := color.RGBA{0, 255, 255, 255} // Yellow for CUT lines // Digit 3 check line digit3Color := redColor if digitIsOne[3] { digit3Color = greenColor } gocv.Line(&visImg, image.Pt(w-8, 0), image.Pt(w-8, h), digit3Color, 2) gocv.PutText(&visImg, "D3", image.Pt(w-8+5, 20), gocv.FontHersheyDuplex, 0.5, digit3Color, 1) // Digit 2 check line (calculate position from accumulated width) var digit2Width int if digitIsOne[3] { digit2Width = DIGIT_ONE_WIDTH } else { digit2Width = DIGIT_NON_ONE_WIDTH } digit2CheckPos := w - digit2Width - 5 digit2Color := redColor if digitIsOne[2] { digit2Color = greenColor } gocv.Line(&visImg, image.Pt(digit2CheckPos, 0), image.Pt(digit2CheckPos, h), digit2Color, 2) gocv.PutText(&visImg, "D2", image.Pt(digit2CheckPos+5, 40), gocv.FontHersheyDuplex, 0.5, digit2Color, 1) // Digit 1 check line (calculate position from accumulated width) var digit3Width int if digitIsOne[3] { digit3Width = DIGIT_ONE_WIDTH } else { digit3Width = DIGIT_NON_ONE_WIDTH } var digit2WidthForD1 int if digitIsOne[2] { digit2WidthForD1 = DIGIT_ONE_WIDTH } else { digit2WidthForD1 = DIGIT_NON_ONE_WIDTH } digit1CheckPos := w - digit3Width - digit2WidthForD1 - 5 digit1Color := redColor if digitIsOne[1] { digit1Color = greenColor } gocv.Line(&visImg, image.Pt(digit1CheckPos, 0), image.Pt(digit1CheckPos, h), digit1Color, 2) gocv.PutText(&visImg, "D1", image.Pt(digit1CheckPos+5, 60), gocv.FontHersheyDuplex, 0.5, digit1Color, 1) // Draw CUT lines in yellow showing where we actually split the digits gocv.Line(&visImg, image.Pt(D[2], 0), image.Pt(D[2], h), yellowColor, 3) gocv.PutText(&visImg, "CUT1", image.Pt(D[2]+5, h-10), gocv.FontHersheyDuplex, 0.6, yellowColor, 2) gocv.Line(&visImg, image.Pt(D[3], 0), image.Pt(D[3], h), yellowColor, 3) gocv.PutText(&visImg, "CUT2", image.Pt(D[3]+5, h-30), gocv.FontHersheyDuplex, 0.6, yellowColor, 2) // Save visualization if DEBUG_MODE { visFilename := fmt.Sprintf("review/f%d_%s_checks.png", frameCount, cleanName) gocv.IMWrite(visFilename, visImg) } visImg.Close() // Clamp cut positions to valid range if D[3] < 0 { D[3] = 0 } if D[2] < 0 { D[2] = 0 } // Extract three digit regions logMessage(Both, Debug, " [%s] Extracting digit1: region [0..%d] (width=%d)", displayName, D[2], D[2]) digit1Region := thresh.Region(image.Rect(0, 0, D[2], h)) digit1 := digit1Region.Clone() digit1Region.Close() logMessage(Both, Debug, " [%s] Extracting digit2: region [%d..%d] (width=%d)", displayName, D[2], D[3], D[3]-D[2]) digit2Region := thresh.Region(image.Rect(D[2], 0, D[3], h)) digit2 := digit2Region.Clone() digit2Region.Close() logMessage(Both, Debug, " [%s] Extracting digit3: region [%d..%d] (width=%d)", displayName, D[3], w, w-D[3]) digit3Region := thresh.Region(image.Rect(D[3], 0, w, h)) digit3 := digit3Region.Clone() digit3Region.Close() // Match all three digits matchStart := time.Now() num1, score1 := matchDigit(digit1, templates) num2, score2 := matchDigit(digit2, templates) num3, score3 := matchDigit(digit3, templates) logMessage(LogFile, Debug, " [TIMING][%s] matchDigit x3: %dms", displayName, time.Since(matchStart).Milliseconds()) // Validate '8' digits - check for two holes if num1 == 8 && !validateEight(digit1, logger) { logMessage(LogFile, Debug, " ⚠️ Digit1 recognized as 8 but failed hole validation - marking invalid") num1 = -1 score1 = 0.0 } if num2 == 8 && !validateEight(digit2, logger) { logMessage(LogFile, Debug, " ⚠️ Digit2 recognized as 8 but failed hole validation - marking invalid") num2 = -1 score2 = 0.0 } if num3 == 8 && !validateEight(digit3, logger) { logMessage(LogFile, Debug, " ⚠️ Digit3 recognized as 8 but failed hole validation - marking invalid") num3 = -1 score3 = 0.0 } // Validate '0' digits - check for center hole if num1 == 0 && !validateZero(digit1, logger) { logMessage(LogFile, Debug, " ⚠️ Digit1 recognized as 0 but failed hole validation - marking invalid") num1 = -1 score1 = 0.0 } if num2 == 0 && !validateZero(digit2, logger) { logMessage(LogFile, Debug, " ⚠️ Digit2 recognized as 0 but failed hole validation - marking invalid") num2 = -1 score2 = 0.0 } if num3 == 0 && !validateZero(digit3, logger) { logMessage(LogFile, Debug, " ⚠️ Digit3 recognized as 0 but failed hole validation - marking invalid") num3 = -1 score3 = 0.0 } // Calculate final number var number int if digitIsOne[1] { // 3-digit number: 1XX number = 100 + num2*10 + num3 } else { // 2-digit number: XX (digit1 region is mostly empty/padding) number = num2*10 + num3 } // Save individual digits if DEBUG_MODE { saveStart := time.Now() digit1Filename := fmt.Sprintf("review/f%d_%s_digit1.png", frameCount, cleanName) digit2Filename := fmt.Sprintf("review/f%d_%s_digit2.png", frameCount, cleanName) digit3Filename := fmt.Sprintf("review/f%d_%s_digit3.png", frameCount, cleanName) gocv.IMWrite(digit1Filename, digit1) gocv.IMWrite(digit2Filename, digit2) gocv.IMWrite(digit3Filename, digit3) logMessage(LogFile, Debug, " [TIMING][%s] Save digit images: %dms", displayName, time.Since(saveStart).Milliseconds()) } // Create labeled full image (combine all three digits) combinedWidth := digit1.Cols() + digit2.Cols() + digit3.Cols() combinedHeight := digit1.Rows() if digit2.Rows() > combinedHeight { combinedHeight = digit2.Rows() } if digit3.Rows() > combinedHeight { combinedHeight = digit3.Rows() } fullForLabel := gocv.NewMatWithSize(combinedHeight, combinedWidth, digit1.Type()) // Copy digit1 digit1ROI := fullForLabel.Region(image.Rect(0, 0, digit1.Cols(), digit1.Rows())) digit1.CopyTo(&digit1ROI) digit1ROI.Close() // Copy digit2 digit2ROI := fullForLabel.Region(image.Rect(digit1.Cols(), 0, digit1.Cols()+digit2.Cols(), digit2.Rows())) digit2.CopyTo(&digit2ROI) digit2ROI.Close() // Copy digit3 digit3ROI := fullForLabel.Region(image.Rect(digit1.Cols()+digit2.Cols(), 0, combinedWidth, digit3.Rows())) digit3.CopyTo(&digit3ROI) digit3ROI.Close() // Add padding at top for label labeledFull := gocv.NewMat() gocv.CopyMakeBorder(fullForLabel, &labeledFull, 60, 0, 0, 0, gocv.BorderConstant, color.RGBA{0, 0, 0, 255}) fullForLabel.Close() // Draw label label := fmt.Sprintf("%d", number) textColor := color.RGBA{255, 255, 255, 255} gocv.PutText(&labeledFull, label, image.Pt(10, 45), gocv.FontHersheyDuplex, 1.8, textColor, 3) // Save labeled full image if DEBUG_MODE { fullFilename := fmt.Sprintf("review/f%d_%s_full.png", frameCount, cleanName) gocv.IMWrite(fullFilename, labeledFull) } labeledFull.Close() digit1.Close() digit2.Close() digit3.Close() thresh.Close() // Calculate widths for logging digit3Width = DIGIT_ONE_WIDTH if !digitIsOne[3] { digit3Width = DIGIT_NON_ONE_WIDTH } digit2Width = DIGIT_ONE_WIDTH if !digitIsOne[2] { digit2Width = DIGIT_NON_ONE_WIDTH } // Print debug info to log only if digitIsOne[1] { logMessage(LogFile, Info, " %s: 1%d%d [3-DIGIT: d1=1@%dpx (x=0-%d), d2=%s@%dpx (x=%d-%d), d3=%s@%dpx (x=%d-%d)] (scores: %d=%.0f%%, %d=%.0f%%, %d=%.0f%%)", displayName, num2, num3, D[2], D[2], map[bool]string{true: "1", false: "X"}[digitIsOne[2]], digit2Width, D[2], D[3], map[bool]string{true: "1", false: "X"}[digitIsOne[3]], digit3Width, D[3], w, num1, score1, num2, score2, num3, score3) } else { logMessage(LogFile, Info, " %s: %d%d [2-DIGIT: d2=%s@%dpx (x=%d-%d), d3=%s@%dpx (x=%d-%d)] (scores: %d=%.0f%%, %d=%.0f%%)", displayName, num2, num3, map[bool]string{true: "1", false: "X"}[digitIsOne[2]], digit2Width, D[2], D[3], map[bool]string{true: "1", false: "X"}[digitIsOne[3]], digit3Width, D[3], w, num2, score2, num3, score3) } logMessage(LogFile, Debug, " [TIMING][%s] Total recognizeDisplayArea: %dms", displayName, time.Since(startTime).Milliseconds()) // Return using middle two digits for compatibility with existing code return number, num2, score2, num3, score3 } // Old version - kept for backward compatibility func recognizeDisplay(rotated gocv.Mat, display Display, templates map[int][]gocv.Mat, displayName string, frameCount int) (int, int, float64, int, float64) { cleanName := strings.TrimSpace(strings.ToLower(displayName)) var leftDigit, rightDigit gocv.Mat var isThreeDigit bool if display.IsSplit { // Single contour - split in half region := rotated.Region(display.FullRect) displayImg := region.Clone() region.Close() gray := gocv.NewMat() gocv.CvtColor(displayImg, &gray, gocv.ColorBGRToGray) displayImg.Close() thresh := gocv.NewMat() gocv.Threshold(gray, &thresh, 128, 255, gocv.ThresholdBinary) gray.Close() w := thresh.Cols() h := thresh.Rows() // Check if this is a 3-digit display (significantly wider) aspectRatio := float64(w) / float64(h) if aspectRatio > 1.55 { // 3-digit displays are much wider // 3-digit display: assume first digit is 1, crop it out isThreeDigit = true // Cut off left ~8% (the narrow "1" digit) cutPoint := w / 12 // Approximately 8.3% croppedRegion := thresh.Region(image.Rect(cutPoint, 0, w, h)) cropped := croppedRegion.Clone() croppedRegion.Close() thresh.Close() // Now split the remaining 2 digits w2 := cropped.Cols() h2 := cropped.Rows() mid := w2 / 2 leftRegion := cropped.Region(image.Rect(0, 0, mid, h2)) leftDigit = leftRegion.Clone() leftRegion.Close() rightRegion := cropped.Region(image.Rect(mid, 0, w2, h2)) rightDigit = rightRegion.Clone() rightRegion.Close() cropped.Close() } else { // Normal 2-digit display isThreeDigit = false mid := w / 2 leftRegion := thresh.Region(image.Rect(0, 0, mid, h)) leftDigit = leftRegion.Clone() leftRegion.Close() rightRegion := thresh.Region(image.Rect(mid, 0, w, h)) rightDigit = rightRegion.Clone() rightRegion.Close() thresh.Close() } } else { // Two separate contours - use directly leftRegion := rotated.Region(display.LeftRect) leftImg := leftRegion.Clone() leftRegion.Close() leftGray := gocv.NewMat() gocv.CvtColor(leftImg, &leftGray, gocv.ColorBGRToGray) leftImg.Close() leftDigit = gocv.NewMat() gocv.Threshold(leftGray, &leftDigit, 128, 255, gocv.ThresholdBinary) leftGray.Close() rightRegion := rotated.Region(display.RightRect) rightImg := rightRegion.Clone() rightRegion.Close() rightGray := gocv.NewMat() gocv.CvtColor(rightImg, &rightGray, gocv.ColorBGRToGray) rightImg.Close() rightDigit = gocv.NewMat() gocv.Threshold(rightGray, &rightDigit, 128, 255, gocv.ThresholdBinary) rightGray.Close() } leftFilename := fmt.Sprintf("review/f%d_%s_left.png", frameCount, cleanName) rightFilename := fmt.Sprintf("review/f%d_%s_right.png", frameCount, cleanName) gocv.IMWrite(leftFilename, leftDigit) gocv.IMWrite(rightFilename, rightDigit) leftNum, leftScore := matchDigit(leftDigit, templates) rightNum, rightScore := matchDigit(rightDigit, templates) var number int if isThreeDigit { number = 100 + leftNum*10 + rightNum } else { number = leftNum*10 + rightNum } // Create labeled full image with recognized value at top var fullForLabel gocv.Mat if display.IsSplit { // Recreate thresh for SPLIT mode region := rotated.Region(display.FullRect) displayImg := region.Clone() region.Close() gray := gocv.NewMat() gocv.CvtColor(displayImg, &gray, gocv.ColorBGRToGray) displayImg.Close() fullForLabel = gocv.NewMat() gocv.Threshold(gray, &fullForLabel, 128, 255, gocv.ThresholdBinary) gray.Close() } else { // Combine left and right for DUAL mode combinedWidth := leftDigit.Cols() + rightDigit.Cols() combinedHeight := leftDigit.Rows() if rightDigit.Rows() > combinedHeight { combinedHeight = rightDigit.Rows() } fullForLabel = gocv.NewMatWithSize(combinedHeight, combinedWidth, leftDigit.Type()) leftROI := fullForLabel.Region(image.Rect(0, 0, leftDigit.Cols(), leftDigit.Rows())) leftDigit.CopyTo(&leftROI) leftROI.Close() rightROI := fullForLabel.Region(image.Rect(leftDigit.Cols(), 0, combinedWidth, rightDigit.Rows())) rightDigit.CopyTo(&rightROI) rightROI.Close() } // Add padding at top for label labeledFull := gocv.NewMat() gocv.CopyMakeBorder(fullForLabel, &labeledFull, 60, 0, 0, 0, gocv.BorderConstant, color.RGBA{0, 0, 0, 255}) fullForLabel.Close() // Draw label with larger font label := fmt.Sprintf("%d", number) textColor := color.RGBA{255, 255, 255, 255} gocv.PutText(&labeledFull, label, image.Pt(10, 45), gocv.FontHersheyDuplex, 1.8, textColor, 3) // Save labeled full image fullFilename := fmt.Sprintf("review/f%d_%s_full.png", frameCount, cleanName) gocv.IMWrite(fullFilename, labeledFull) labeledFull.Close() leftDigit.Close() rightDigit.Close() // Note: This old function is kept for backward compatibility but should use logger if needed // For now, keeping console output since this isn't actively used return number, leftNum, leftScore, rightNum, rightScore }