diff --git a/src/common/AbstractBlackBoxTestCase.java b/src/common/AbstractBlackBoxTestCase.java new file mode 100644 index 0000000..eefbe43 --- /dev/null +++ b/src/common/AbstractBlackBoxTestCase.java @@ -0,0 +1,357 @@ +/* + * Copyright 2008 ZXing authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.zxing.common; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.BinaryBitmap; +import com.google.zxing.BufferedImageLuminanceSource; +import com.google.zxing.DecodeHintType; +import com.google.zxing.LuminanceSource; +import com.google.zxing.Reader; +import com.google.zxing.ReaderException; +import com.google.zxing.RXingResult; +import com.google.zxing.RXingResultMetadataType; +import org.junit.Assert; +import org.junit.Test; + +import javax.imageio.ImageIO; +import java.awt.Graphics; +import java.awt.geom.AffineTransform; +import java.awt.geom.RectangularShape; +import java.awt.image.AffineTransformOp; +import java.awt.image.BufferedImage; +import java.awt.image.BufferedImageOp; +import java.io.BufferedReader; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.logging.Logger; + +/** + * @author Sean Owen + * @author dswitkin@google.com (Daniel Switkin) + */ +public abstract class AbstractBlackBoxTestCase extends Assert { + + private static final Logger log = Logger.getLogger(AbstractBlackBoxTestCase.class.getSimpleName()); + + private final Path testBase; + private final Reader barcodeReader; + private final BarcodeFormat expectedFormat; + private final List testRXingResults; + private final EnumMap hints = new EnumMap<>(DecodeHintType.class); + + public static Path buildTestBase(String testBasePathSuffix) { + // A little workaround to prevent aggravation in my IDE + Path testBase = Paths.get(testBasePathSuffix); + if (!Files.exists(testBase)) { + // try starting with 'core' since the test base is often given as the project root + testBase = Paths.get("core").resolve(testBasePathSuffix); + } + return testBase; + } + + protected AbstractBlackBoxTestCase(String testBasePathSuffix, + Reader barcodeReader, + BarcodeFormat expectedFormat) { + this.testBase = buildTestBase(testBasePathSuffix); + this.barcodeReader = barcodeReader; + this.expectedFormat = expectedFormat; + testRXingResults = new ArrayList<>(); + + System.setProperty("java.util.logging.SimpleFormatter.format", "%4$s: %5$s%6$s%n"); + } + + protected final Path getTestBase() { + return testBase; + } + + protected final void addTest(int mustPassCount, int tryHarderCount, float rotation) { + addTest(mustPassCount, tryHarderCount, 0, 0, rotation); + } + + protected void addHint(DecodeHintType hint) { + hints.put(hint, Boolean.TRUE); + } + + /** + * Adds a new test for the current directory of images. + * + * @param mustPassCount The number of images which must decode for the test to pass. + * @param tryHarderCount The number of images which must pass using the try harder flag. + * @param maxMisreads Maximum number of images which can fail due to successfully reading the wrong contents + * @param maxTryHarderMisreads Maximum number of images which can fail due to successfully + * reading the wrong contents using the try harder flag + * @param rotation The rotation in degrees clockwise to use for this test. + */ + protected final void addTest(int mustPassCount, + int tryHarderCount, + int maxMisreads, + int maxTryHarderMisreads, + float rotation) { + testRXingResults.add(new TestRXingResult(mustPassCount, tryHarderCount, maxMisreads, maxTryHarderMisreads, rotation)); + } + + protected final List getImageFiles() throws IOException { + assertTrue("Please download and install test images, and run from the 'core' directory", Files.exists(testBase)); + List paths = new ArrayList<>(); + try (DirectoryStream pathIt = Files.newDirectoryStream(testBase, "*.{jpg,jpeg,gif,png,JPG,JPEG,GIF,PNG}")) { + for (Path path : pathIt) { + paths.add(path); + } + } + return paths; + } + + final Reader getReader() { + return barcodeReader; + } + + @Test + public void testBlackBox() throws IOException { + assertFalse(testRXingResults.isEmpty()); + + List imageFiles = getImageFiles(); + int testCount = testRXingResults.size(); + + int[] passedCounts = new int[testCount]; + int[] misreadCounts = new int[testCount]; + int[] tryHarderCounts = new int[testCount]; + int[] tryHarderMisreadCounts = new int[testCount]; + + for (Path testImage : imageFiles) { + log.info(String.format("Starting %s", testImage)); + + BufferedImage image = ImageIO.read(testImage.toFile()); + + String testImageFileName = testImage.getFileName().toString(); + String fileBaseName = testImageFileName.substring(0, testImageFileName.indexOf('.')); + Path expectedTextFile = testBase.resolve(fileBaseName + ".txt"); + String expectedText; + if (Files.exists(expectedTextFile)) { + expectedText = readFileAsString(expectedTextFile, StandardCharsets.UTF_8); + } else { + expectedTextFile = testBase.resolve(fileBaseName + ".bin"); + assertTrue(Files.exists(expectedTextFile)); + expectedText = readFileAsString(expectedTextFile, StandardCharsets.ISO_8859_1); + } + + Path expectedMetadataFile = testBase.resolve(fileBaseName + ".metadata.txt"); + Properties expectedMetadata = new Properties(); + if (Files.exists(expectedMetadataFile)) { + try (BufferedReader reader = Files.newBufferedReader(expectedMetadataFile, StandardCharsets.UTF_8)) { + expectedMetadata.load(reader); + } + } + + for (int x = 0; x < testCount; x++) { + float rotation = testRXingResults.get(x).getRotation(); + BufferedImage rotatedImage = rotateImage(image, rotation); + LuminanceSource source = new BufferedImageLuminanceSource(rotatedImage); + BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source)); + try { + if (decode(bitmap, rotation, expectedText, expectedMetadata, false)) { + passedCounts[x]++; + } else { + misreadCounts[x]++; + } + } catch (ReaderException ignored) { + log.fine(String.format("could not read at rotation %f", rotation)); + } + try { + if (decode(bitmap, rotation, expectedText, expectedMetadata, true)) { + tryHarderCounts[x]++; + } else { + tryHarderMisreadCounts[x]++; + } + } catch (ReaderException ignored) { + log.fine(String.format("could not read at rotation %f w/TH", rotation)); + } + } + } + + // Print the results of all tests first + int totalFound = 0; + int totalMustPass = 0; + int totalMisread = 0; + int totalMaxMisread = 0; + + for (int x = 0; x < testRXingResults.size(); x++) { + TestRXingResult testRXingResult = testRXingResults.get(x); + log.info(String.format("Rotation %d degrees:", (int) testRXingResult.getRotation())); + log.info(String.format(" %d of %d images passed (%d required)", + passedCounts[x], imageFiles.size(), testRXingResult.getMustPassCount())); + int failed = imageFiles.size() - passedCounts[x]; + log.info(String.format(" %d failed due to misreads, %d not detected", + misreadCounts[x], failed - misreadCounts[x])); + log.info(String.format(" %d of %d images passed with try harder (%d required)", + tryHarderCounts[x], imageFiles.size(), testRXingResult.getTryHarderCount())); + failed = imageFiles.size() - tryHarderCounts[x]; + log.info(String.format(" %d failed due to misreads, %d not detected", + tryHarderMisreadCounts[x], failed - tryHarderMisreadCounts[x])); + totalFound += passedCounts[x] + tryHarderCounts[x]; + totalMustPass += testRXingResult.getMustPassCount() + testRXingResult.getTryHarderCount(); + totalMisread += misreadCounts[x] + tryHarderMisreadCounts[x]; + totalMaxMisread += testRXingResult.getMaxMisreads() + testRXingResult.getMaxTryHarderMisreads(); + } + + int totalTests = imageFiles.size() * testCount * 2; + log.info(String.format("Decoded %d images out of %d (%d%%, %d required)", + totalFound, totalTests, totalFound * 100 / totalTests, totalMustPass)); + if (totalFound > totalMustPass) { + log.warning(String.format("+++ Test too lax by %d images", totalFound - totalMustPass)); + } else if (totalFound < totalMustPass) { + log.warning(String.format("--- Test failed by %d images", totalMustPass - totalFound)); + } + + if (totalMisread < totalMaxMisread) { + log.warning(String.format("+++ Test expects too many misreads by %d images", totalMaxMisread - totalMisread)); + } else if (totalMisread > totalMaxMisread) { + log.warning(String.format("--- Test had too many misreads by %d images", totalMisread - totalMaxMisread)); + } + + // Then run through again and assert if any failed + for (int x = 0; x < testCount; x++) { + TestRXingResult testRXingResult = testRXingResults.get(x); + String label = "Rotation " + testRXingResult.getRotation() + " degrees: Too many images failed"; + assertTrue(label, + passedCounts[x] >= testRXingResult.getMustPassCount()); + assertTrue("Try harder, " + label, + tryHarderCounts[x] >= testRXingResult.getTryHarderCount()); + label = "Rotation " + testRXingResult.getRotation() + " degrees: Too many images misread"; + assertTrue(label, + misreadCounts[x] <= testRXingResult.getMaxMisreads()); + assertTrue("Try harder, " + label, + tryHarderMisreadCounts[x] <= testRXingResult.getMaxTryHarderMisreads()); + } + } + + private boolean decode(BinaryBitmap source, + float rotation, + String expectedText, + Map expectedMetadata, + boolean tryHarder) throws ReaderException { + + String suffix = String.format(" (%srotation: %d)", tryHarder ? "try harder, " : "", (int) rotation); + + Map hints = this.hints.clone(); + if (tryHarder) { + hints.put(DecodeHintType.TRY_HARDER, Boolean.TRUE); + } + + // Try in 'pure' mode mostly to exercise PURE_BARCODE code paths for exceptions; + // not expected to pass, generally + RXingResult result = null; + try { + Map pureHints = new EnumMap<>(hints); + pureHints.put(DecodeHintType.PURE_BARCODE, Boolean.TRUE); + result = barcodeReader.decode(source, pureHints); + } catch (ReaderException re) { + // continue + } + + if (result == null) { + result = barcodeReader.decode(source, hints); + } + + if (expectedFormat != result.getBarcodeFormat()) { + log.info(String.format("Format mismatch: expected '%s' but got '%s'%s", + expectedFormat, result.getBarcodeFormat(), suffix)); + return false; + } + + String resultText = result.getText(); + if (!expectedText.equals(resultText)) { + log.info(String.format("Content mismatch: expected '%s' but got '%s'%s", + expectedText, resultText, suffix)); + return false; + } + + Map resultMetadata = result.getRXingResultMetadata(); + for (Map.Entry metadatum : expectedMetadata.entrySet()) { + RXingResultMetadataType key = RXingResultMetadataType.valueOf(metadatum.getKey().toString()); + Object expectedValue = metadatum.getValue(); + Object actualValue = resultMetadata == null ? null : resultMetadata.get(key); + if (!expectedValue.equals(actualValue)) { + log.info(String.format("Metadata mismatch for key '%s': expected '%s' but got '%s'", + key, expectedValue, actualValue)); + return false; + } + } + + return true; + } + + protected static String readFileAsString(Path file, Charset charset) throws IOException { + String stringContents = new String(Files.readAllBytes(file), charset); + if (stringContents.endsWith("\n")) { + log.info("String contents of file " + file + " end with a newline. " + + "This may not be intended and cause a test failure"); + } + return stringContents; + } + + protected static BufferedImage rotateImage(BufferedImage original, float degrees) { + if (degrees == 0.0f) { + return original; + } + + switch (original.getType()) { + case BufferedImage.TYPE_BYTE_INDEXED: + case BufferedImage.TYPE_BYTE_BINARY: + BufferedImage argb = new BufferedImage(original.getWidth(), + original.getHeight(), + BufferedImage.TYPE_INT_ARGB); + Graphics g = argb.createGraphics(); + g.drawImage(original, 0, 0, null); + g.dispose(); + original = argb; + break; + } + + double radians = Math.toRadians(degrees); + + // Transform simply to find out the new bounding box (don't actually run the image through it) + AffineTransform at = new AffineTransform(); + at.rotate(radians, original.getWidth() / 2.0, original.getHeight() / 2.0); + BufferedImageOp op = new AffineTransformOp(at, AffineTransformOp.TYPE_BICUBIC); + + RectangularShape r = op.getBounds2D(original); + int width = (int) Math.ceil(r.getWidth()); + int height = (int) Math.ceil(r.getHeight()); + + // Real transform, now that we know the size of the new image and how to translate after we rotate + // to keep it centered + at = new AffineTransform(); + at.rotate(radians, width / 2.0, height / 2.0); + at.translate((width - original.getWidth()) / 2.0, + (height - original.getHeight()) / 2.0); + op = new AffineTransformOp(at, AffineTransformOp.TYPE_BICUBIC); + + return op.filter(original, new BufferedImage(width, height, original.getType())); + } + +} diff --git a/src/common/AbstractNegativeBlackBoxTestCase.java b/src/common/AbstractNegativeBlackBoxTestCase.java new file mode 100644 index 0000000..0b734cc --- /dev/null +++ b/src/common/AbstractNegativeBlackBoxTestCase.java @@ -0,0 +1,159 @@ +/* + * Copyright 2008 ZXing authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.zxing.common; + +import com.google.zxing.BinaryBitmap; +import com.google.zxing.BufferedImageLuminanceSource; +import com.google.zxing.DecodeHintType; +import com.google.zxing.LuminanceSource; +import com.google.zxing.MultiFormatReader; +import com.google.zxing.ReaderException; +import com.google.zxing.RXingResult; +import org.junit.Test; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; + +/** + * This abstract class looks for negative results, i.e. it only allows a certain number of false + * positives in images which should not decode. This helps ensure that we are not too lenient. + * + * @author dswitkin@google.com (Daniel Switkin) + */ +public abstract class AbstractNegativeBlackBoxTestCase extends AbstractBlackBoxTestCase { + + private static final Logger log = Logger.getLogger(AbstractNegativeBlackBoxTestCase.class.getSimpleName()); + + private final List testRXingResults; + + private static final class TestRXingResult { + private final int falsePositivesAllowed; + private final float rotation; + + TestRXingResult(int falsePositivesAllowed, float rotation) { + this.falsePositivesAllowed = falsePositivesAllowed; + this.rotation = rotation; + } + + int getFalsePositivesAllowed() { + return falsePositivesAllowed; + } + + float getRotation() { + return rotation; + } + } + + // Use the multiformat reader to evaluate all decoders in the system. + protected AbstractNegativeBlackBoxTestCase(String testBasePathSuffix) { + super(testBasePathSuffix, new MultiFormatReader(), null); + testRXingResults = new ArrayList<>(); + } + + protected final void addTest(int falsePositivesAllowed, float rotation) { + testRXingResults.add(new TestRXingResult(falsePositivesAllowed, rotation)); + } + + @Override + @Test + public void testBlackBox() throws IOException { + assertFalse(testRXingResults.isEmpty()); + + List imageFiles = getImageFiles(); + int[] falsePositives = new int[testRXingResults.size()]; + for (Path testImage : imageFiles) { + log.info(String.format("Starting %s", testImage)); + BufferedImage image = ImageIO.read(testImage.toFile()); + if (image == null) { + throw new IOException("Could not read image: " + testImage); + } + for (int x = 0; x < testRXingResults.size(); x++) { + TestRXingResult testRXingResult = testRXingResults.get(x); + if (!checkForFalsePositives(image, testRXingResult.getRotation())) { + falsePositives[x]++; + } + } + } + + int totalFalsePositives = 0; + int totalAllowed = 0; + + for (int x = 0; x < testRXingResults.size(); x++) { + TestRXingResult testRXingResult = testRXingResults.get(x); + totalFalsePositives += falsePositives[x]; + totalAllowed += testRXingResult.getFalsePositivesAllowed(); + } + + if (totalFalsePositives < totalAllowed) { + log.warning(String.format("+++ Test too lax by %d images", totalAllowed - totalFalsePositives)); + } else if (totalFalsePositives > totalAllowed) { + log.warning(String.format("--- Test failed by %d images", totalFalsePositives - totalAllowed)); + } + + for (int x = 0; x < testRXingResults.size(); x++) { + TestRXingResult testRXingResult = testRXingResults.get(x); + log.info(String.format("Rotation %d degrees: %d of %d images were false positives (%d allowed)", + (int) testRXingResult.getRotation(), falsePositives[x], imageFiles.size(), + testRXingResult.getFalsePositivesAllowed())); + assertTrue("Rotation " + testRXingResult.getRotation() + " degrees: Too many false positives found", + falsePositives[x] <= testRXingResult.getFalsePositivesAllowed()); + } + } + + /** + * Make sure ZXing does NOT find a barcode in the image. + * + * @param image The image to test + * @param rotationInDegrees The amount of rotation to apply + * @return true if nothing found, false if a non-existent barcode was detected + */ + private boolean checkForFalsePositives(BufferedImage image, float rotationInDegrees) { + BufferedImage rotatedImage = rotateImage(image, rotationInDegrees); + LuminanceSource source = new BufferedImageLuminanceSource(rotatedImage); + BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source)); + RXingResult result; + try { + result = getReader().decode(bitmap); + log.info(String.format("Found false positive: '%s' with format '%s' (rotation: %d)", + result.getText(), result.getBarcodeFormat(), (int) rotationInDegrees)); + return false; + } catch (ReaderException re) { + // continue + } + + // Try "try harder" getMode + Map hints = new EnumMap<>(DecodeHintType.class); + hints.put(DecodeHintType.TRY_HARDER, Boolean.TRUE); + try { + result = getReader().decode(bitmap, hints); + log.info(String.format("Try harder found false positive: '%s' with format '%s' (rotation: %d)", + result.getText(), result.getBarcodeFormat(), (int) rotationInDegrees)); + return false; + } catch (ReaderException re) { + // continue + } + return true; + } + +} diff --git a/src/common/BitSourceTestCase.java b/src/common/BitSourceTestCase.java new file mode 100644 index 0000000..1ff714a --- /dev/null +++ b/src/common/BitSourceTestCase.java @@ -0,0 +1,48 @@ +/* + * Copyright 2007 ZXing authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.zxing.common; + +import org.junit.Assert; +import org.junit.Test; + +/** + * @author Sean Owen + */ +public final class BitSourceTestCase extends Assert { + + @Test + public void testSource() { + byte[] bytes = {(byte) 1, (byte) 2, (byte) 3, (byte) 4, (byte) 5}; + BitSource source = new BitSource(bytes); + assertEquals(40, source.available()); + assertEquals(0, source.readBits(1)); + assertEquals(39, source.available()); + assertEquals(0, source.readBits(6)); + assertEquals(33, source.available()); + assertEquals(1, source.readBits(1)); + assertEquals(32, source.available()); + assertEquals(2, source.readBits(8)); + assertEquals(24, source.available()); + assertEquals(12, source.readBits(10)); + assertEquals(14, source.available()); + assertEquals(16, source.readBits(8)); + assertEquals(6, source.available()); + assertEquals(5, source.readBits(6)); + assertEquals(0, source.available()); + } + +} \ No newline at end of file diff --git a/src/common/PerspectiveTransformTestCase.java b/src/common/PerspectiveTransformTestCase.java new file mode 100644 index 0000000..651c6fb --- /dev/null +++ b/src/common/PerspectiveTransformTestCase.java @@ -0,0 +1,65 @@ +/* + * Copyright 2007 ZXing authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.zxing.common; + +import org.junit.Assert; +import org.junit.Test; + +/** + * @author Sean Owen + */ +public final class PerspectiveTransformTestCase extends Assert { + + private static final float EPSILON = 1.0E-4f; + + @Test + public void testSquareToQuadrilateral() { + PerspectiveTransform pt = PerspectiveTransform.squareToQuadrilateral( + 2.0f, 3.0f, 10.0f, 4.0f, 16.0f, 15.0f, 4.0f, 9.0f); + assertPointEquals(2.0f, 3.0f, 0.0f, 0.0f, pt); + assertPointEquals(10.0f, 4.0f, 1.0f, 0.0f, pt); + assertPointEquals(4.0f, 9.0f, 0.0f, 1.0f, pt); + assertPointEquals(16.0f, 15.0f, 1.0f, 1.0f, pt); + assertPointEquals(6.535211f, 6.8873234f, 0.5f, 0.5f, pt); + assertPointEquals(48.0f, 42.42857f, 1.5f, 1.5f, pt); + } + + @Test + public void testQuadrilateralToQuadrilateral() { + PerspectiveTransform pt = PerspectiveTransform.quadrilateralToQuadrilateral( + 2.0f, 3.0f, 10.0f, 4.0f, 16.0f, 15.0f, 4.0f, 9.0f, + 103.0f, 110.0f, 300.0f, 120.0f, 290.0f, 270.0f, 150.0f, 280.0f); + assertPointEquals(103.0f, 110.0f, 2.0f, 3.0f, pt); + assertPointEquals(300.0f, 120.0f, 10.0f, 4.0f, pt); + assertPointEquals(290.0f, 270.0f, 16.0f, 15.0f, pt); + assertPointEquals(150.0f, 280.0f, 4.0f, 9.0f, pt); + assertPointEquals(7.1516876f, -64.60185f, 0.5f, 0.5f, pt); + assertPointEquals(328.09116f, 334.16385f, 50.0f, 50.0f, pt); + } + + private static void assertPointEquals(float expectedX, + float expectedY, + float sourceX, + float sourceY, + PerspectiveTransform pt) { + float[] points = {sourceX, sourceY}; + pt.transformPoints(points); + assertEquals(expectedX, points[0], EPSILON); + assertEquals(expectedY, points[1], EPSILON); + } + +} \ No newline at end of file