Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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/
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.idea
*.egg-info
__pycache__
build/
tests/data/*renamed.jpg
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~

## <a name="deps"></a>Dependencies
All dependencies are standard pip installable packages. They are automatically installed with setup script.

Expand Down
34 changes: 29 additions & 5 deletions im/im.py
Original file line number Diff line number Diff line change
@@ -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 *
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion im/utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import numpy as np
from PIL import Image
import piexif
from PIL import Image


def imread(filepath):
Expand Down
32 changes: 32 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]
3 changes: 3 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
114 changes: 114 additions & 0 deletions tests/test_im.py
Original file line number Diff line number Diff line change
@@ -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
Loading