diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..49ac8fc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/ruff-action@v3 + + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: pip install -e ".[test]" + - run: pytest tests/ diff --git a/.gitignore b/.gitignore index 3ffa354..b36483e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea *.egg-info __pycache__ +build/ tests/data/*renamed.jpg diff --git a/README.md b/README.md index 7a68f79..5b09978 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,15 @@ im gray lena.jpg - Overwrite original file (be careful), using `-w`. - Use batch processing, using list of images (or globing), `im gray *.jpg`. +## Development + +Run lint and tests locally: +~~~bash +pip install -e ".[test]" +ruff check im/ tests/ +pytest tests/ -v +~~~ + ## Dependencies All dependencies are standard pip installable packages. They are automatically installed with setup script. diff --git a/im/im.py b/im/im.py index 8906815..9da811f 100644 --- a/im/im.py +++ b/im/im.py @@ -1,13 +1,13 @@ +import argparse +import multiprocessing as mp import os -from functools import partial import shutil import sys import traceback from datetime import datetime -import argparse +from functools import partial -from PIL import ImageOps, Image -import multiprocessing as mp +from PIL import Image, ImageOps from im.display import CursesDisplay from im.utils import * @@ -51,6 +51,14 @@ def im_cmd(): parser_exif.add_argument('--comment', '-c', help='Comment.', type=str, default=None) parser_exif.add_argument('--overwrite', '-w', help='Overwrite input images.', action='store_true') + parser_flip = subparsers.add_parser('flip', description='Flip image horizontally or vertically.', + help='Flip image horizontally or vertically.', + formatter_class=argparse.ArgumentDefaultsHelpFormatter) + parser_flip.set_defaults(func=flip) + parser_flip.add_argument('files', metavar='FILE', nargs='+', type=str) + parser_flip.add_argument('--vertical', '-v', help='Flip vertically (top to bottom).', action='store_true') + parser_flip.add_argument('--overwrite', '-w', help='Overwrite input images.', action='store_true') + parser_rotate = subparsers.add_parser('rotate', description='Rotate image according to exif data', help='Rotate image according to exif data', formatter_class=argparse.ArgumentDefaultsHelpFormatter) @@ -188,7 +196,7 @@ def stack(files: list, output: str, vertical: bool): sizes = [im.shape[i_shape] for im in ims] i_max = sizes.index(max(sizes)) for i, im in enumerate(ims): - if i is not i_max: + if i != i_max: f = ims[i_max].shape[i_shape] / im.shape[i_shape] img = Image.fromarray(ims[i]) h, w = list(ims[i].shape)[:2] @@ -271,6 +279,22 @@ def exif(files: list, remove: bool, comment: str, overwrite: bool): _exif_show(exf) +def flip(files: list, vertical: bool, overwrite: bool): + for m_input in files: + if overwrite: + out_file = m_input + else: + path_base, ext = os.path.splitext(m_input) + out_file = '%s_flipped%s' % (path_base, ext) + print(m_input, '-->', out_file, 'flipping ...') + image, exf = imread(m_input) + if vertical: + image = ImageOps.flip(image) + else: + image = ImageOps.mirror(image) + imwrite(image, out_file, exf) + + def rotate(files: list, overwrite: bool): for i, m_input in enumerate(files): if overwrite: diff --git a/im/utils.py b/im/utils.py index 7dbe2f2..838ca5d 100644 --- a/im/utils.py +++ b/im/utils.py @@ -1,6 +1,6 @@ import numpy as np -from PIL import Image import piexif +from PIL import Image def imread(filepath): diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cacd663 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[tool.ruff] +target-version = "py310" +exclude = ["build"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "W", # pycodestyle warnings + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear + "SIM", # flake8-simplify + "PIE", # flake8-pie +] +ignore = [ + "E501", # line too long (let formatter handle it) + "E722", # bare except (used intentionally for image loading fallbacks) + "E741", # ambiguous variable name (i, l, etc. are fine here) + "B007", # unused loop variable + "F403", # star imports (used intentionally for utils) + "F405", # names from star imports (consequence of F403) + "SIM105", # use contextlib.suppress (not always clearer) + "SIM108", # ternary operator (not always clearer) + "UP031", # printf string formatting (existing style) +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["E", "W"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/setup.py b/setup.py index eff3609..7812069 100644 --- a/setup.py +++ b/setup.py @@ -14,6 +14,9 @@ 'Pillow>=11.1.0', 'piexif>=1.1.3' ], + extras_require={ + 'test': ['pytest', 'ruff'], + }, entry_points=''' [console_scripts] im=im.im:im_cmd diff --git a/tests/test_im.py b/tests/test_im.py new file mode 100644 index 0000000..5432f44 --- /dev/null +++ b/tests/test_im.py @@ -0,0 +1,114 @@ +import os + +import numpy as np +import pytest +from PIL import Image + +from im.im import border, convert, crop, flip, gray, info, resize, stack + + +@pytest.fixture +def sample_image(tmp_path): + """Create a simple test RGB image.""" + img = Image.fromarray(np.random.randint(0, 255, (100, 150, 3), dtype=np.uint8)) + path = str(tmp_path / "test.png") + img.save(path) + return path + + +@pytest.fixture +def sample_image_jpg(tmp_path): + """Create a simple test RGB image in JPEG format.""" + img = Image.fromarray(np.random.randint(0, 255, (100, 150, 3), dtype=np.uint8)) + path = str(tmp_path / "test.jpg") + img.save(path) + return path + + +def test_gray(sample_image, tmp_path): + gray(files=[sample_image], overwrite=False) + out = str(tmp_path / "test_gray.png") + assert os.path.exists(out) + img = Image.open(out) + assert img.mode == "L" + + +def test_gray_overwrite(sample_image): + gray(files=[sample_image], overwrite=True) + img = Image.open(sample_image) + assert img.mode == "L" + + +def test_resize(sample_image, tmp_path): + resize(files=[sample_image], overwrite=False, size=50, width=0, height=0) + out = str(tmp_path / "test_resized.png") + assert os.path.exists(out) + img = Image.open(out) + assert max(img.size) == 50 + + +def test_resize_explicit_dims(sample_image, tmp_path): + resize(files=[sample_image], overwrite=False, size=1000, width=80, height=60) + out = str(tmp_path / "test_resized.png") + img = Image.open(out) + assert img.size == (80, 60) + + +def test_flip_horizontal(sample_image, tmp_path): + flip(files=[sample_image], vertical=False, overwrite=False) + out = str(tmp_path / "test_flipped.png") + assert os.path.exists(out) + original = np.asarray(Image.open(sample_image)) + flipped = np.asarray(Image.open(out)) + np.testing.assert_array_equal(flipped, original[:, ::-1, :]) + + +def test_flip_vertical(sample_image, tmp_path): + flip(files=[sample_image], vertical=True, overwrite=False) + out = str(tmp_path / "test_flipped.png") + original = np.asarray(Image.open(sample_image)) + flipped = np.asarray(Image.open(out)) + np.testing.assert_array_equal(flipped, original[::-1, :, :]) + + +def test_crop(sample_image, tmp_path): + crop(files=[sample_image], x=10, y=20, width=50, height=30, overwrite=False) + out = str(tmp_path / "test_cropped.png") + assert os.path.exists(out) + img = Image.open(out) + assert img.size == (50, 30) + + +def test_border(sample_image, tmp_path): + border(files=[sample_image], width=5, color="red", overwrite=False) + out = str(tmp_path / "test_border.png") + assert os.path.exists(out) + img = Image.open(out) + assert img.size == (160, 110) # 150+2*5, 100+2*5 + + +def test_convert(sample_image, tmp_path): + convert(files=[sample_image], extension=".jpg", overwrite=False) + out = str(tmp_path / "test.jpg") + assert os.path.exists(out) + + +def test_stack_horizontal(tmp_path): + img1 = Image.fromarray(np.zeros((100, 50, 3), dtype=np.uint8)) + img2 = Image.fromarray(np.zeros((100, 80, 3), dtype=np.uint8)) + p1 = str(tmp_path / "a.png") + p2 = str(tmp_path / "b.png") + img1.save(p1) + img2.save(p2) + out = str(tmp_path / "stacked.png") + stack(files=[p1, p2], output=out, vertical=False) + assert os.path.exists(out) + result = Image.open(out) + assert result.size[1] == 100 # height preserved + + +def test_info(sample_image, capsys): + info(files=[sample_image]) + captured = capsys.readouterr() + assert "Size" in captured.out + assert "Mode" in captured.out