diff --git a/CHANGELOG.md b/CHANGELOG.md index 264748c..4e555e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # v0.1.7 -- #15 Mac OS X build of decode_barcodes +- #15 Mac OS X build of decode_barcodes +- #14 Suffixes to resolve filename collisions # v0.1.6 - #12 bin to scripts diff --git a/gouda/scripts/decode_barcode.py b/gouda/scripts/decode_barcode.py deleted file mode 100644 index 0c26b0b..0000000 --- a/gouda/scripts/decode_barcode.py +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env python -from __future__ import print_function - -import argparse -import csv -import sys -import time -import traceback - -import cv2 - -import gouda -import gouda.util - -from gouda.engines.options import engine_options -from gouda.gouda_error import GoudaError -from gouda.util import expand_wildcard, read_image -from gouda.strategies.roi.roi import roi -from gouda.strategies.resize import resize - - -# TODO LH Visitor that copies file p to a new file for each decoded barcode -# value (see Chris' process) - -def decode(paths, strategies, engine, visitors, read_greyscale): - """Finds and decodes barcodes in images given in paths - """ - for p in paths: - if p.is_dir(): - # Descend into directory - decode(p.iterdir(), strategies, engine, visitors, read_greyscale) - else: - # Process file - try: - img = read_image(p, read_greyscale) - if img is None: - # Most likely not an image - for visitor in visitors: - visitor.result(p, [None, []]) - else: - # Read barcodes - for strategy in strategies: - result = strategy(img, engine) - if result: - # Found a barcode - break - else: - # No barcode was found - result = [None, []] - - for visitor in visitors: - visitor.result(p, result) - except Exception: - print('Error processing [{0}]'.format(p)) - traceback.print_exc() - - -class BasicReportVisitor(object): - """Writes a line-per-file and a line-per-barcode to stdout - """ - def result(self, path, result): - print(path) - strategy, barcodes = result - print('Found [{0}] barcodes:'.format(len(barcodes))) - for index, barcode in enumerate(barcodes): - print('[{0}] [{1}] [{2}]'.format(index, barcode.type, barcode.data)) - - -class TerseReportVisitor(object): - """Writes a line-per-file to stdout - """ - def result(self, path, result): - strategy, barcodes = result - values = [b.data for b in barcodes] - print(path, ' '.join(['[{0}]'.format(v) for v in values])) - - -class CSVReportVisitor(object): - """Writes a CSV report - """ - def __init__(self, engine, greyscale, file=sys.stdout): - self.w = csv.writer(file, lineterminator='\n') - self.w.writerow([ - 'OS', 'Engine', 'Directory', 'File', 'Image.conversion', - 'Elapsed', 'N.found', 'Types', 'Values', 'Strategy' - ]) - self.engine = engine - self.image_conversion = 'Greyscale' if greyscale else 'Unchanged' - self.start_time = time.time() - - def result(self, path, result): - strategy, barcodes = result - types = '|'.join(b.type for b in barcodes) - values = '|'.join(b.data for b in barcodes) - self.w.writerow([sys.platform, - self.engine, - path.parent.name, - path.name, - self.image_conversion, - time.time()-self.start_time, - len(barcodes), - types, - values, - strategy]) - - -class RenameReporter(object): - """Renames files based on their barcodes - """ - def result(self, path, result): - strategy, barcodes = result - print(path) - if not barcodes: - print(' No barcodes') - else: - barcodes = [ - b.data.replace('(', '-').replace(')', '') for b in barcodes - ] - fname = '_'.join(barcodes) - dest = path.parent / (fname + path.suffix) - if path == dest: - print(' Already correctly named') - elif dest.is_file(): - msg = ' Cannot rename to [{0}] because destination exists' - print(msg.format(dest)) - else: - path.rename(dest) - print(' Renamed to [{0}]'.format(dest)) - - -if __name__ == '__main__': - # TODO ROI candidate area max and/or min? - # TODO Give area min and max as percentage of total image area? - # TODO Swallow zbar warnings? - - parser = argparse.ArgumentParser( - description='Finds and decodes barcodes on images' - ) - parser.add_argument('--debug', '-d', action='store_true') - parser.add_argument( - '--action', '-a', - choices=['basic', 'terse', 'csv', 'rename'], default='basic' - ) - parser.add_argument('--greyscale', '-g', action='store_true') - - options = engine_options() - if not options: - raise GoudaError('No engines are available') - parser.add_argument('engine', choices=sorted(options.keys())) - parser.add_argument( - 'image', nargs='+', help='path to an image or a directory' - ) - parser.add_argument('-v', '--version', action='version', - version='%(prog)s ' + gouda.__version__) - - args = parser.parse_args() - - gouda.util.DEBUG_PRINT = args.debug - - engine = options[args.engine]() - - if 'csv' == args.action: - visitor = CSVReportVisitor(args.engine, args.greyscale) - elif 'terse' == args.action: - visitor = TerseReportVisitor() - elif 'rename' == args.action: - visitor = RenameReporter() - else: - visitor = BasicReportVisitor() - - strategies = [resize, roi] - decode(expand_wildcard(args.image), strategies, engine, [visitor], - args.greyscale) diff --git a/gouda/scripts/decode_barcodes.py b/gouda/scripts/decode_barcodes.py index 39299de..706b8b5 100755 --- a/gouda/scripts/decode_barcodes.py +++ b/gouda/scripts/decode_barcodes.py @@ -9,7 +9,9 @@ import time import traceback -import cv2 +from collections import defaultdict +from itertools import count +from functools import partial import gouda import gouda.util @@ -22,7 +24,7 @@ def decode(paths, strategies, engine, visitors, read_greyscale): - """Finds and decodes barcodes in images given in paths + """Finds and decodes barcodes in images given in pathss """ for p in paths: if p.is_dir(): @@ -103,9 +105,32 @@ def result(self, path, result): strategy]) -class RenameReporter(object): +class RenameVisitor(object): """Renames files based on their barcodes """ + def __init__(self, avoid_collisions): + self.avoid_collisions = avoid_collisions + # Mapping from path to iterator of integer suffixes, used to avoid + # collisions - see self._destination + self.suffix = defaultdict(partial(count, start=1)) + + def _destination(self, path): + """Returns path possibly with a suffix appended to the name to avoid + collisions with existing files, iff self.avoid_collisions is True, + otherwise path is returned unaltered. + """ + destination = path + if self.avoid_collisions: + while destination.is_file(): + fname = u'{0}-{1}{2}'.format( + path.stem, + next(self.suffix[path.name]), + path.suffix + ) + destination = path.with_name(fname) + + return destination + def result(self, path, result): strategy, barcodes = result print(path) @@ -122,6 +147,7 @@ def result(self, path, result): first_destination = None for value in values: dest = path.with_name(u'{0}{1}'.format(value, path.suffix)) + dest = self._destination(dest) source = first_destination if first_destination else path rename = not bool(first_destination) if source == dest: @@ -139,8 +165,8 @@ def result(self, path, result): first_destination = dest -if __name__ == '__main__': - # TODO ROI candidate area max and/or min? +def main(args): + # TODO LH ROI candidate area max and/or min? # TODO Give area min and max as percentage of total image area? # TODO Report barcode regions - both normalised and absolute coords? # TODO Swallow zbar warnings? @@ -154,6 +180,11 @@ def result(self, path, result): choices=['basic', 'terse', 'csv', 'rename'], default='basic' ) parser.add_argument('--greyscale', '-g', action='store_true') + parser.add_argument( + '--avoid-collisions', action='store_true', + help=('If the action is "rename", appends a suffix to renamed files to ' + 'prevent collisions') + ) options = engine_options() if not options: @@ -164,7 +195,7 @@ def result(self, path, result): parser.add_argument('-v', '--version', action='version', version='%(prog)s ' + gouda.__version__) - args = parser.parse_args() + args = parser.parse_args(args) gouda.util.DEBUG_PRINT = args.debug @@ -175,10 +206,14 @@ def result(self, path, result): elif 'terse' == args.action: visitor = TerseReportVisitor() elif 'rename' == args.action: - visitor = RenameReporter() + visitor = RenameVisitor(args.avoid_collisions) else: visitor = BasicReportVisitor() strategies = [resize, roi] decode(expand_wildcard(args.image), strategies, engine, [visitor], args.greyscale) + + +if __name__ == '__main__': + main(sys.argv[1:]) diff --git a/gouda/tests/test_decode_barcodes.py b/gouda/tests/test_decode_barcodes.py new file mode 100644 index 0000000..8d02c16 --- /dev/null +++ b/gouda/tests/test_decode_barcodes.py @@ -0,0 +1,76 @@ +import unittest +import shutil + +from pathlib import Path + +from gouda.engines import ZbarEngine +from gouda.scripts.decode_barcodes import main + +from utils import temp_directory_with_files + + +TESTDATA = Path(__file__).parent.joinpath('test_data') + + +@unittest.skipUnless(ZbarEngine.available(), 'ZbarEngine unavailable') +class TestRename(unittest.TestCase): + def test_rename(self): + "File is renamed with value of barcode" + with temp_directory_with_files(TESTDATA.joinpath('code128.png')) as tempdir: + main(['zbar', '--action=rename', unicode(tempdir)]) + self.assertEqual( + ['Stegosaurus.png'], + [path.name for path in sorted(tempdir.iterdir())] + ) + + def test_rename_multiple(self): + "File with multiple barcodes results in renamed / copied to three files" + with temp_directory_with_files(TESTDATA.joinpath('BM001128287.jpg')) as tempdir: + main(['zbar', '--action=rename', unicode(tempdir)]) + self.assertEqual( + ['BM001128286.jpg', 'BM001128287.jpg', 'BM001128288.jpg'], + [path.name for path in sorted(tempdir.iterdir())] + ) + + def test_rename_with_collisions(self): + "Files with same barcode values results in just a single rename" + with temp_directory_with_files(TESTDATA.joinpath('code128.png')) as tempdir: + shutil.copy( + unicode(TESTDATA.joinpath('code128.png')), + unicode(tempdir.joinpath('first copy.png')) + ) + + shutil.copy( + unicode(TESTDATA.joinpath('code128.png')), + unicode(tempdir.joinpath('second copy.png')) + ) + + main(['zbar', '--action=rename', unicode(tempdir)]) + self.assertEqual( + ['Stegosaurus.png', 'first copy.png', 'second copy.png'], + [path.name for path in sorted(tempdir.iterdir())] + ) + + def test_rename_avoid_collisions(self): + "Files with same barcode values results in new files with suffixes" + with temp_directory_with_files(TESTDATA.joinpath('code128.png')) as tempdir: + shutil.copy( + unicode(TESTDATA.joinpath('code128.png')), + unicode(tempdir.joinpath('first copy.png')) + ) + + shutil.copy( + unicode(TESTDATA.joinpath('code128.png')), + unicode(tempdir.joinpath('second copy.png')) + ) + + main(['zbar', '--action=rename', unicode(tempdir), '--avoid-collisions']) + print([path.name for path in sorted(tempdir.iterdir())]) + self.assertEqual( + ['Stegosaurus-1.png', 'Stegosaurus-2.png', 'Stegosaurus.png'], + [path.name for path in sorted(tempdir.iterdir())] + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/gouda/tests/utils.py b/gouda/tests/utils.py new file mode 100644 index 0000000..2ec7955 --- /dev/null +++ b/gouda/tests/utils.py @@ -0,0 +1,20 @@ +import shutil +import tempfile + +from contextlib import contextmanager +from pathlib import Path + + +@contextmanager +def temp_directory_with_files(*paths): + """A context manager that creates a temporary directory and copies all + paths to it. The temporary directory is unlinked when the context is exited. + """ + temp = tempfile.mkdtemp() + try: + temp = Path(temp) + for p in paths: + shutil.copy(str(p), str(temp / Path(p).name)) + yield temp + finally: + shutil.rmtree(str(temp))