Skip to content

Commit c9e9687

Browse files
committed
chore: Integration test with images
1 parent 9eb332f commit c9e9687

6 files changed

Lines changed: 179 additions & 11 deletions

File tree

.github/workflows/publish.yaml

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,41 +9,68 @@ on:
99
env:
1010
REGISTRY: ghcr.io
1111
IMAGE_NAME: ${{ github.repository }}
12+
OCR_TEST_IMAGE: meter-reader-ocr-test
1213

1314
permissions:
1415
contents: write
1516
packages: write
1617
pull-requests: write
1718

1819
jobs:
20+
release-please:
21+
runs-on: ubuntu-latest
22+
outputs:
23+
release_created: ${{ steps.release.outputs.release_created }}
24+
tag_name: ${{ steps.release.outputs.tag_name }}
25+
steps:
26+
- uses: googleapis/release-please-action@v4
27+
id: release
28+
with:
29+
release-type: simple
30+
1931
test:
2032
runs-on: ubuntu-latest
2133
steps:
2234
- name: Checkout
2335
uses: actions/checkout@v4
2436

2537
- name: Set up Go
26-
uses: actions/setup-go@v6
38+
uses: actions/setup-go@v5
2739
with:
2840
go-version-file: go.mod
2941

30-
- name: Run tests
42+
- name: Unit tests
3143
run: go test -race -v ./...
3244

33-
release-please:
34-
needs: test
45+
integration-test:
3546
runs-on: ubuntu-latest
36-
outputs:
37-
release_created: ${{ steps.release.outputs.release_created }}
38-
tag_name: ${{ steps.release.outputs.tag_name }}
3947
steps:
40-
- uses: googleapis/release-please-action@v4
41-
id: release
48+
- name: Checkout
49+
uses: actions/checkout@v4
50+
51+
- name: Set up Go
52+
uses: actions/setup-go@v5
4253
with:
43-
release-type: simple
54+
go-version-file: go.mod
55+
56+
- name: Set up Docker Buildx
57+
uses: docker/setup-buildx-action@v3
58+
59+
- name: Build OCR test image
60+
uses: docker/build-push-action@v6
61+
with:
62+
context: .
63+
file: Dockerfile.test
64+
load: true
65+
tags: ${{ env.OCR_TEST_IMAGE }}
66+
cache-from: type=gha,scope=ocr-test
67+
cache-to: type=gha,scope=ocr-test,mode=max
68+
69+
- name: Integration tests
70+
run: go test -v -run TestIntegration -timeout 600s ./...
4471

4572
build-and-push:
46-
needs: [release-please, test]
73+
needs: [release-please, test, integration-test]
4774
if: needs.release-please.outputs.release_created == 'true' || github.event_name == 'workflow_dispatch'
4875
runs-on: ubuntu-latest
4976
steps:

Dockerfile.test

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
FROM python:3.13-slim-bookworm
2+
3+
RUN apt-get update && apt-get install -y --no-install-recommends \
4+
libgomp1 libglib2.0-0 libgl1 \
5+
&& rm -rf /var/lib/apt/lists/*
6+
7+
WORKDIR /app
8+
9+
# Pin paddlepaddle to 3.2.2 — later 3.x versions have a CPU inference bug:
10+
# https://github.com/PaddlePaddle/Paddle/issues/77340
11+
RUN python3 -m venv /app/venv \
12+
&& /app/venv/bin/pip install --no-cache-dir \
13+
paddlepaddle==3.2.2 \
14+
-i https://www.paddlepaddle.org.cn/packages/stable/cpu/ \
15+
&& /app/venv/bin/pip install --no-cache-dir paddleocr
16+
17+
# Pre-download models by doing a dummy inference
18+
COPY ocr.py /app/ocr.py
19+
RUN apt-get update && apt-get install -y --no-install-recommends wget \
20+
&& wget -q -O /tmp/test.png https://paddle-model-ecology.bj.bcebos.com/paddlex/imgs/demo_image/general_ocr_002.png \
21+
&& /app/venv/bin/python3 /app/ocr.py /tmp/test.png > /dev/null 2>&1 \
22+
&& rm /tmp/test.png \
23+
&& apt-get purge -y wget && apt-get autoremove -y \
24+
&& rm -rf /var/lib/apt/lists/*
25+
26+
ENV PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK=True
27+
28+
ENTRYPOINT ["/app/venv/bin/python3", "/app/ocr.py"]

integration_test.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"math"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"regexp"
11+
"strconv"
12+
"testing"
13+
)
14+
15+
const ocrTestImage = "meter-reader-ocr-test"
16+
17+
func TestIntegrationOCR(t *testing.T) {
18+
if _, err := exec.LookPath("docker"); err != nil {
19+
t.Skip("skipping: docker not found")
20+
}
21+
if entries, _ := filepath.Glob("testdata/*.jpg"); len(entries) == 0 {
22+
t.Skip("skipping: no testdata/*.jpg files found")
23+
}
24+
25+
// Build or reuse the OCR test image.
26+
check := exec.Command("docker", "image", "inspect", ocrTestImage)
27+
if err := check.Run(); err != nil {
28+
t.Log("building Docker image for OCR integration tests (this may take a few minutes on first run)...")
29+
cmd := exec.Command("docker", "build", "-f", "Dockerfile.test", "-t", ocrTestImage, ".")
30+
cmd.Stdout = os.Stdout
31+
cmd.Stderr = os.Stderr
32+
if err := cmd.Run(); err != nil {
33+
t.Fatalf("failed to build test image: %v", err)
34+
}
35+
} else {
36+
t.Log("using existing Docker image:", ocrTestImage)
37+
}
38+
39+
cropCfg := &cropConfig{X0: 850, Y0: 630, X1: 1470, Y1: 795}
40+
matchRe := regexp.MustCompile(`^000\d+$`)
41+
fixRules := parseOCRFixRules("^300=000,^800=000,^900=000")
42+
masks := parseMaskRegions(
43+
"405,21,451,43,457,27,499,45,507,29,550,47,68,125,136,163",
44+
"8a9986,8a9986,8a9986,cef2ce",
45+
)
46+
divisor := 1000.0
47+
48+
tests := []struct {
49+
file string
50+
want float64
51+
}{
52+
{"testdata/0.jpg", 355.797},
53+
{"testdata/1.jpg", 355.914},
54+
{"testdata/2.jpg", 355.920},
55+
}
56+
57+
for _, tt := range tests {
58+
t.Run(filepath.Base(tt.file), func(t *testing.T) {
59+
imageData, err := os.ReadFile(tt.file)
60+
if err != nil {
61+
t.Fatalf("read image: %v", err)
62+
}
63+
64+
processed, err := cropImage(imageData, cropCfg)
65+
if err != nil {
66+
t.Fatalf("crop: %v", err)
67+
}
68+
69+
processed, err = maskImage(processed, masks)
70+
if err != nil {
71+
t.Fatalf("mask: %v", err)
72+
}
73+
74+
tmpDir := t.TempDir()
75+
tmpFile := filepath.Join(tmpDir, "image.jpg")
76+
if err := os.WriteFile(tmpFile, processed, 0644); err != nil {
77+
t.Fatalf("write temp file: %v", err)
78+
}
79+
80+
cmd := exec.Command("docker", "run", "--rm",
81+
"-v", tmpDir+":/data:ro",
82+
ocrTestImage,
83+
"/data/image.jpg",
84+
)
85+
var stdout, stderr bytes.Buffer
86+
cmd.Stdout = &stdout
87+
cmd.Stderr = &stderr
88+
if err := cmd.Run(); err != nil {
89+
t.Fatalf("docker run: %v\nstderr: %s", err, stderr.String())
90+
}
91+
92+
var ocrOut ocrOutput
93+
if err := json.Unmarshal(stdout.Bytes(), &ocrOut); err != nil {
94+
t.Fatalf("parse OCR output: %v (raw: %s)", err, stdout.String())
95+
}
96+
97+
reading := extractReading(ocrOut.Texts, matchRe, fixRules)
98+
if reading == "" {
99+
t.Fatalf("no reading extracted from texts: %v", ocrOut.Texts)
100+
}
101+
102+
raw, err := strconv.ParseFloat(reading, 64)
103+
if err != nil {
104+
t.Fatalf("parse reading %q: %v", reading, err)
105+
}
106+
107+
got := raw / divisor
108+
if math.Abs(got-tt.want) > 0.001 {
109+
t.Errorf("reading = %.3f, want %.3f (raw=%s, texts=%v)", got, tt.want, reading, ocrOut.Texts)
110+
}
111+
})
112+
}
113+
}

testdata/0.jpg

103 KB
Loading

testdata/1.jpg

103 KB
Loading

testdata/2.jpg

103 KB
Loading

0 commit comments

Comments
 (0)