baidu ocr 识别增值税专用发票图片的 PowerShell 脚本

参考

根据纳税人查验的票种,其输入的校验项目也不相同,其中:
(1)增值税专用发票、增值税电子专用发票:发票代码、发票号码、开票日期和开具金额(不含税);
(2)增值税普通发票、增值税电子普通发票(含通行费发票)、增值税普通发票(卷票):发票代码、发票号码、开票日期和校验码后六位;
(3)机动车销售统一发票:发票代码、发票号码、开票日期和不含税价;
(4)二手车销售统一发票发票:发票代码、发票号码、开票日期和车价合计;
(5)电子发票(增值税专用发票)、电子发票(普通发票):发票号码(20位)、开票日期、价税合计;
(6)电子发票服务平台开具的纸质增值税专用发票:发票代码、发票号码、开票日期和开具金额(不含税);
(7)电子发票服务平台开具的纸质增值税普通发票:发票代码、发票号码、开票日期和校验码(密码区发票号码后六位);
(8)电子发票(铁路电子客票):发票号码(20位)、开票日期和票价;
(9)电子发票(航空运输电子客票行程单):发票号码(20位)、开票日期、开票金额(价税合计)。

baidu_image_ocr.ps1

param(
    [string]$ApiKey = "",
    [string]$SecretKey = "",
    [string]$AccessToken = "",
    [string]$InputFolderPath = "./",
    [string[]]$ImageExtensions = @("*.png", "*.jpg", "*.jpeg", "*.bmp"),
    [bool]$OverwriteExistingJson = $true
)

#region Configuration
# 脚本开始时尝试设置控制台输出编码为 UTF-8
try {
    [System.Console]::OutputEncoding = [System.Text.Encoding]::UTF8
    Write-Host "控制台输出编码已设置为 UTF-8。" -ForegroundColor Green
} catch {
    Write-Warning "设置控制台输出编码为 UTF-8 失败"
}

$ErrorActionPreference = "Continue"

# 兼容旧变量名 (如果用户没传参,使用默认值)
if (-not $inputFolderPath) { $inputFolderPath = $InputFolderPath }
if (-not $imageExtensions) { $imageExtensions = $ImageExtensions }

# DEBUG: 输出接收到的参数
Write-Host "DEBUG: ApiKey=['$ApiKey']" -ForegroundColor DarkGray
Write-Host "DEBUG: SecretKey=['$SecretKey']" -ForegroundColor DarkGray
Write-Host "DEBUG: AccessToken=['$AccessToken'] Length=$($AccessToken.Length)" -ForegroundColor DarkGray

# 图片处理配置

# 图片处理配置
$targetSizeKB = 4096          # 目标文件大小 (KB)
$minQuality = 30            # 最低质量阈值
$maxWidth = 3000            # 最大宽度像素
$maxHeight = 4000           # 最大高度像素
$initialQuality = 80        # 初始JPEG质量
$minJsonSizeKB = 1          # 最小JSON文件大小(KB),小于此大小认为识别失败
#endregion Configuration

#region Helper Functions
function Get-OcrValue {
    param($InputObject)
    if ($null -eq $InputObject) { return "" }
    if ($InputObject -is [string]) { return $InputObject }
    if ($InputObject -is [System.ValueType]) { return [string]$InputObject }
    if ($InputObject.PSObject.Properties['word']) { return $InputObject.word }
    return [string]$InputObject
}

function Get-BaiduAccessToken {
    param(
        [Parameter(Mandatory=$true)][string]$ApiKey,
        [Parameter(Mandatory=$true)][string]$SecretKey
    )
    
    $url = "https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=$ApiKey&client_secret=$SecretKey"
    
    try {
        Write-Host "正在获取 Access Token..." -ForegroundColor Gray
        $response = Invoke-RestMethod -Uri $url -Method Get
        
        if ($response.access_token) {
            Write-Host  $response.access_token
            return $response.access_token
        } else {
            Write-Error "获取Access Token失败: $($response.error_description)"
            return $null
        }
    } catch {
        Write-Error "获取Access Token网络请求失败: $($_.Exception.Message)"
        return $null
    }
}

function Is-ValidInvoiceCode {
    param([string]$code)

    if ([string]::IsNullOrWhiteSpace($code)) {
        return $false
    }

    # 发票代码应该是10位或12位数字
    $cleanCode = $code -replace '[^\d]', ''
    $codeLength = $cleanCode.Length

    if ($codeLength -eq 10 -or $codeLength -eq 12) {
        return $true
    }
    return $false
}

function Is-SuccessfulJson {
    param([string]$jsonPath)

    if (-not (Test-Path $jsonPath)) { return $false }

    $fileInfo = Get-Item $jsonPath
    if ($fileInfo.Length -lt ($minJsonSizeKB * 1KB)) {
        Write-Host "    JSON文件过小 ($([math]::Round($fileInfo.Length/1KB, 2))KB),可能识别失败" -ForegroundColor Yellow
        return $false
    }

    try {
        $jsonContent = Get-Content -Path $jsonPath -Raw -ErrorAction Stop | ConvertFrom-Json
        
        # 检查是否有错误码
        if ($jsonContent.error_code) {
             Write-Host "    JSON包含错误码: $($jsonContent.error_code) - $($jsonContent.error_msg)" -ForegroundColor Yellow
             return $false
        }

        # 检查是否有 words_result
        if ($null -eq $jsonContent.words_result) {
            Write-Host "    JSON缺少 words_result 字段" -ForegroundColor Yellow
            return $false
        }

        return $true
    } catch {
        Write-Host "    JSON解析失败: $_" -ForegroundColor Red
        return $false
    }
}

function Compress-ImageToTargetSize {
    param (
        [string]$ImagePath,
        [int]$TargetSizeKB,
        [int]$MaxWidth,
        [int]$MaxHeight,
        [int]$Quality
    )

    try {
        # 加载图片
        $image = [System.Drawing.Image]::FromFile($ImagePath)
        $originalWidth = $image.Width
        $originalHeight = $image.Height
        $fileSize = (Get-Item $ImagePath).Length
        $fileSizeKB = $fileSize / 1KB

        Write-Host "    处理图片: $ImagePath" -ForegroundColor Cyan
        Write-Host "    原始图片: ${originalWidth}x${originalHeight}, 大小: $([math]::Round($fileSizeKB, 2)) KB" -ForegroundColor Gray

        # 如果文件大小和尺寸都在范围内,直接使用原图
        if ($fileSizeKB -le $TargetSizeKB -and $originalWidth -le $MaxWidth -and $originalHeight -le $MaxHeight) {
            Write-Host "    ? 原图符合要求,跳过压缩" -ForegroundColor Green
            $image.Dispose()
            return [System.IO.File]::ReadAllBytes($ImagePath)
        }

        # 计算压缩后的尺寸
        if ($originalWidth -gt $MaxWidth -or $originalHeight -gt $MaxHeight) {
            $ratio = [Math]::Min($MaxWidth / $originalWidth, $MaxHeight / $originalHeight)
            $newWidth = [int]($originalWidth * $ratio)
            $newHeight = [int]($originalHeight * $ratio)
        } else {
            $newWidth = $originalWidth
            $newHeight = $originalHeight
        }

        $attempt = 1
        $maxAttempts = 3
        $compressedBytes = $null

        while ($attempt -le $maxAttempts) {
            $memoryStream = New-Object System.IO.MemoryStream
            $newImage = New-Object System.Drawing.Bitmap($newWidth, $newHeight)
            $graphics = [System.Drawing.Graphics]::FromImage($newImage)
            $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
            $graphics.DrawImage($image, 0, 0, $newWidth, $newHeight)
            $graphics.Dispose()

            # 设置JPEG压缩质量
            $jpegEncoderInfo = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq "image/jpeg" }
            $encoderParameters = New-Object System.Drawing.Imaging.EncoderParameters(1)
            $encoderParameters.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter([System.Drawing.Imaging.Encoder]::Quality, $Quality)

            $newImage.Save($memoryStream, $jpegEncoderInfo, $encoderParameters)
            $tempBytes = $memoryStream.ToArray()
            $compressedSizeKB = $tempBytes.Length / 1KB

            Write-Host "    压缩尝试 $attempt, 质量: $Quality%, 尺寸: ${newWidth}x${newHeight}" -ForegroundColor Gray
            Write-Host "    压缩后大小: $([math]::Round($compressedSizeKB, 2)) KB" -ForegroundColor Gray

            if ($compressedSizeKB -le $TargetSizeKB) {
                $compressedBytes = $tempBytes
                $newImage.Dispose()
                $memoryStream.Dispose()
                Write-Host "    ? 压缩成功" -ForegroundColor Green
                break
            }

            # 如果还是太大,降低质量和尺寸继续尝试
            $Quality -= 20
            $newWidth = [int]($newWidth * 0.8)
            $newHeight = [int]($newHeight * 0.8)
            $newImage.Dispose()
            $memoryStream.Dispose()
            $attempt++
        }

        $image.Dispose()
        return $compressedBytes

    } catch {
        Write-Error "    图片压缩失败: $_"
        if ($image) { $image.Dispose() }
        return $null
    }
}

function Invoke-BaiduOcrForFile {
    param (
        [string]$ImagePath,
        [string]$AccessToken
    )

    # 压缩图片
    $imageBytes = Compress-ImageToTargetSize -ImagePath $ImagePath -TargetSizeKB $targetSizeKB -MaxWidth $maxWidth -MaxHeight $maxHeight -Quality $initialQuality

    if (-not $imageBytes) {
        Write-Error "    无法处理图片,跳过API调用"
        return $null
    }

    $imageBase64 = [Convert]::ToBase64String($imageBytes)
    # 移除可能存在的换行符
    $imageBase64 = $imageBase64 -replace '\s+', ''

    if ([string]::IsNullOrEmpty($imageBase64)) {
        Write-Error "    Base64转换失败"
        return $null
    }

    Write-Host "    Base64长度: $($imageBase64.Length) 字符" -ForegroundColor Gray

    # URL 编码 (使用 System.Net.WebUtility 以获得更好的兼容性)
    $urlEncodedImageBase64 = [System.Net.WebUtility]::UrlEncode($imageBase64)
    
    # API调用
    $apiUrl = "https://aip.baidubce.com/rest/2.0/ocr/v1/vat_invoice?access_token=$AccessToken"
    $body = "image=$urlEncodedImageBase64"

    Write-Host "    发送API请求..." -ForegroundColor Gray

    try {
        $response = Invoke-RestMethod -Uri $apiUrl -Method Post -Body $body -ContentType "application/x-www-form-urlencoded"
        
        # 检查业务逻辑错误
        if ($response.error_code) {
             # 处理特定错误码
             if ($response.error_code -eq 216201 -or $response.error_code -eq 216100 -or $response.error_code -eq 216101 -or $response.error_code -eq 282103) {
                 Write-Warning "    API返回错误 (码=$($response.error_code)): $($response.error_msg)。尝试重新编码/重试..."
             }
             Write-Error "    OCR错误: 码=$($response.error_code), 信息=$($response.error_msg)"
             return $null
        }

        return $response
    } catch {
        Write-Error "    API请求失败: $_"
        return $null
    }
}

function Get-ExistingJsonData {
    param([string]$jsonPath)
    
    try {
        $json = Get-Content -Path $jsonPath -Raw -ErrorAction Stop | ConvertFrom-Json
        $wr = $json.words_result
        
        # 使用 Get-OcrValue 提取值
        $invoiceCode = Get-OcrValue -InputObject $wr.InvoiceCode
        $invoiceNum = Get-OcrValue -InputObject $wr.InvoiceNum
        $invoiceDate = Get-OcrValue -InputObject $wr.InvoiceDate
        
        # 修正金额提取逻辑
        # AmountInFiguers -> 含税金额
        # TotalAmount -> 不含税金额
        $amountTaxIncluded = Get-OcrValue -InputObject $wr.AmountInFiguers
        $amountTaxExcluded = Get-OcrValue -InputObject $wr.TotalAmount

        return @{
            InvoiceCode = $invoiceCode
            InvoiceNumber = $invoiceNum
            InvoiceDate = $invoiceDate
            TaxIncludedAmount = $amountTaxIncluded
            TaxExcludedAmount = $amountTaxExcluded
        }
    } catch {
        Write-Warning "    读取现有JSON失败: $_"
        return $null
    }
}

function Get-InvoiceSummary {
    param($ocrResult)

    if (-not $ocrResult -or -not $ocrResult.words_result) {
        return $null
    }

    $wr = $ocrResult.words_result
    
    # 使用 Get-OcrValue 提取值
    $invoiceCode = Get-OcrValue -InputObject $wr.InvoiceCode
    $invoiceNum = Get-OcrValue -InputObject $wr.InvoiceNum
    $invoiceDate = Get-OcrValue -InputObject $wr.InvoiceDate
    
    # 修正金额提取逻辑
    $amountTaxIncluded = Get-OcrValue -InputObject $wr.AmountInFiguers
    $amountTaxExcluded = Get-OcrValue -InputObject $wr.TotalAmount

    return @{
        InvoiceCode = $invoiceCode
        InvoiceNumber = $invoiceNum
        InvoiceDate = $invoiceDate
        TaxIncludedAmount = $amountTaxIncluded
        TaxExcludedAmount = $amountTaxExcluded
    }
}
#endregion Helper Functions

#region Main Script Logic
# 检查Access Token
# 如果 AccessToken 为空,或者用户提供了 ApiKey (优先使用 ApiKey 获取新 Token)
if ([string]::IsNullOrWhiteSpace($AccessToken) -or (-not [string]::IsNullOrWhiteSpace($ApiKey))) {
    # 尝试使用 ApiKey 和 SecretKey 获取
    if (-not [string]::IsNullOrWhiteSpace($ApiKey) -and -not [string]::IsNullOrWhiteSpace($SecretKey)) {
        Write-Host "尝试使用 API Key 获取 Access Token..." -ForegroundColor Yellow
        $newToken = Get-BaiduAccessToken -ApiKey $ApiKey -SecretKey $SecretKey
        
        if ($newToken) {
            $AccessToken = $newToken
            Write-Host "成功获取 Access Token" -ForegroundColor Green
        } else {
            if ([string]::IsNullOrWhiteSpace($AccessToken)) {
                Write-Error "无法获取 Access Token,且未提供备用 Token。"
                exit 1
            } else {
                Write-Warning "获取新 Token 失败,将尝试使用现有的 Token。"
            }
        }
    } elseif ([string]::IsNullOrWhiteSpace($AccessToken)) {
        Write-Error "错误:必须提供 Access Token,或者同时提供 ApiKey 和 SecretKey"
        exit 1
    }
}

$displayToken = if ($AccessToken.Length -gt 20) { $AccessToken.Substring(0, 20) + "..." } else { $AccessToken }
Write-Host "Access Token: $displayToken" -ForegroundColor Gray

# 检查输入文件夹
if (-not (Test-Path $inputFolderPath -PathType Container)) {
    Write-Error "错误:输入文件夹 '$inputFolderPath' 不存在"
    exit 1
}

# 查找图片文件
$imageFiles = Get-ChildItem -Path $inputFolderPath -Include $imageExtensions -Recurse -File

if (-not $imageFiles) {
    Write-Warning "在文件夹 '$inputFolderPath' 中没有找到图片文件"
    exit
}

$totalFiles = $imageFiles.Count
$processedCount = 0
$successCount = 0
$errorCount = 0
$skippedCount = 0
$reprocessCount = 0
$invoiceSummaries = @()

Write-Host "找到 $totalFiles 个图片文件开始处理`n" -ForegroundColor Green

foreach ($imageFile in $imageFiles) {
    $processedCount++
    $progressPercent = [math]::Round(($processedCount / $totalFiles) * 100, 1)
    Write-Host "--------------------------------------------------" -ForegroundColor DarkGray
    Write-Host "[$progressPercent%] 处理 $processedCount/$totalFiles`: $($imageFile.Name)" -ForegroundColor Cyan

    $jsonPath = [System.IO.Path]::ChangeExtension($imageFile.FullName, ".json")
    $invoiceSummary = $null
    $needApiCall = $true

    # 检查是否存在JSON文件
    if (Test-Path $jsonPath -PathType Leaf) {
        Write-Host "  发现已有JSON文件" -ForegroundColor Yellow

        # 检查JSON是否成功
        if (Is-SuccessfulJson -jsonPath $jsonPath) {
            Write-Host "  JSON识别成功,提取数据..." -ForegroundColor Green

            # 提取已有数据
            $existingData = Get-ExistingJsonData -jsonPath $jsonPath
            
            if ($existingData) {
                # 验证发票代码
                Write-Host "    检查发票码: '$($existingData.InvoiceCode)' -> '$($existingData.InvoiceCode)' ($(if($existingData.InvoiceCode){$existingData.InvoiceCode.Length}else{0}) 位)" -ForegroundColor Gray
                
                if (Is-ValidInvoiceCode -code $existingData.InvoiceCode) {
                    Write-Host "  ? 发票码位数正确,跳过API调用" -ForegroundColor Green
                    $invoiceSummary = $existingData
                    $needApiCall = $false
                    $skippedCount++
                    $successCount++
                } else {
                    Write-Host "  发票码位数不对,需要重新识别" -ForegroundColor Red
                    $reprocessCount++
                }
            } else {
                Write-Host "  JSON数据提取失败,需要重新识别" -ForegroundColor Red
            }
        } else {
            Write-Host "  JSON识别失败,需要重新识别" -ForegroundColor Red
        }
    }

    # 如果需要API调用
    if ($needApiCall) {
        $ocrResult = Invoke-BaiduOcrForFile -ImagePath $imageFile.FullName -AccessToken $accessToken
        
        if ($ocrResult) {
            Write-Host "    ? OCR识别成功" -ForegroundColor Green
            
            # 保存JSON
            $jsonContent = $ocrResult | ConvertTo-Json -Depth 10 -Compress
            $jsonContent | Out-File $jsonPath -Encoding UTF8
            
            # 提取摘要
            $invoiceSummary = Get-InvoiceSummary -ocrResult $ocrResult
            
            if ($invoiceSummary) {
                Write-Host "    检查发票码: '$($invoiceSummary.InvoiceCode)' -> '$($invoiceSummary.InvoiceCode)' ($(if($invoiceSummary.InvoiceCode){$invoiceSummary.InvoiceCode.Length}else{0}) 位)" -ForegroundColor Gray
                
                if (Is-ValidInvoiceCode -code $invoiceSummary.InvoiceCode) {
                    Write-Host "  ? 识别成功,发票码正确" -ForegroundColor Green
                    $successCount++
                } else {
                    Write-Warning "  识别成功,但发票码位数可能不正确"
                    # 仍然计入成功,因为API调用成功了
                    $successCount++
                }
            } else {
                 Write-Warning "  识别成功,但无法提取关键信息"
                 $errorCount++
            }
        } else {
            Write-Error "  识别失败"
            $errorCount++
        }
    }

    # 输出摘要信息
    if ($invoiceSummary) {
        Write-Host "    发票代码: $($invoiceSummary.InvoiceCode)"
        Write-Host "    发票号码: $($invoiceSummary.InvoiceNumber)"
        Write-Host "    开票日期: $($invoiceSummary.InvoiceDate)"
        Write-Host "    含税金额: $($invoiceSummary.TaxIncludedAmount)"
        Write-Host "    不含税额: $($invoiceSummary.TaxExcludedAmount)"
        
        $invoiceSummaries += [PSCustomObject]@{
            FileName = $imageFile.Name
            InvoiceCode = $invoiceSummary.InvoiceCode
            InvoiceNumber = $invoiceSummary.InvoiceNumber
            InvoiceDate = $invoiceSummary.InvoiceDate
            TaxIncludedAmount = $invoiceSummary.TaxIncludedAmount
            TaxExcludedAmount = $invoiceSummary.TaxExcludedAmount
            Status = "Success"
        }
    } else {
        $invoiceSummaries += [PSCustomObject]@{
            FileName = $imageFile.Name
            InvoiceCode = ""
            InvoiceNumber = ""
            InvoiceDate = ""
            TaxIncludedAmount = ""
            TaxExcludedAmount = ""
            Status = "Failed"
        }
    }
}

# 导出汇总CSV
$csvPath = Join-Path $inputFolderPath "invoice_summary.csv"
$invoiceSummaries | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8
Write-Host "`n? 汇总报告已保存到: $csvPath" -ForegroundColor Green

Write-Host "--------------------------------------------------" -ForegroundColor DarkGray
Write-Host "处理完成!" -ForegroundColor Green
Write-Host "总计文件: $totalFiles"
Write-Host "成功识别: $successCount ($([math]::Round($successCount/$totalFiles*100))%)"
Write-Host "跳过处理: $skippedCount (已有成功结果)"
Write-Host "重新处理: $reprocessCount (发票码位数不对)"
Write-Host "识别失败: $errorCount ($([math]::Round($errorCount/$totalFiles*100))%)"
Write-Host "节省API调用: $skippedCount 次"
Write-Host "==================================================" -ForegroundColor Green
#endregion Main Script Logic

用法比如
./baidu_image_ocr.ps1 -ApiKey "<your_api_key>" -SecretKey "<your_secret_key>" -AccessToken=""

./baidu_image_ocr.ps1 -ApiKey "" -SecretKey "" -AccessToken="<your_access_token>"

posted @ 2026-01-08 22:01  geyee  阅读(23)  评论(0)    收藏  举报