4da72c75584207bfc2b7a9a84068389890365a07
[toast/gimp_passport.git] / gimp_passport.py
1 import argparse
2 from itertools import count
3 from numbers import Integral
4 from typing import Tuple, Optional
5 from PIL import Image, ImageDraw
6
7
8 MM_PER_INCH = 25.4
9
10
11 def mm_to_pixel(mm: float, dpi: Integral) -> int:
12     return int(round(mm / MM_PER_INCH * dpi))
13
14
15 def make_passport(img: Image, bbox_head: Tuple[int,int,int,int], fpr: float, width_mm: float, height_mm: float) -> Image:
16     """
17     :param fpr: face to picture ratio, e.g. 2/3
18     """
19     # get bounding box of passport image ("pic")
20     left, top, right, bottom = bbox_head
21     height = bottom - top
22     width = right - left
23     pic_height = int(round(height / fpr))
24     pic_width = int(round(pic_height * width_mm / height_mm))
25     pic_top = top - (pic_height - height) // 2
26     pic_bottom = pic_top + pic_height
27     pic_left = left - (pic_width - width) // 2
28     pic_right = pic_left + pic_width
29     bbox_pic = pic_left, pic_top, pic_right, pic_bottom
30
31     # cut image
32     pic = img.crop(bbox_pic)
33     dpi = int(round(pic_height / (height_mm / MM_PER_INCH)))
34     pic.info['dpi'] = (dpi, dpi)
35     return pic
36
37
38 def num_tiles(paper_size: int, tile_size: int, margin: int, spacing: int) -> int:
39     """Returns the number of tiles that fit in the space described by the parameres.
40     All length units have to be the same (e.g. millimeter or pixel).
41
42     :param paper_size: paper width or paper height
43     :para tile_size: tile width or tile height
44     :param margin: space between paper border and first tile
45     :param spacing: space between tiles
46     """
47     size = paper_size - 2 * margin
48     return max(0, int((size + spacing) // (tile_size + spacing)))
49
50
51 def tile_edge(index: int, length: int, margin: int, spacing: int) -> int:
52     """Returns the left (or top) pixel position (zero based) of the tile with index i.
53     All length units have to be the same (e.g. millimeter or pixel).
54     This function can be used for horizontal (=left) or vertical (=top) edges.
55     The other edge (right or bottom) can be calculated by adding the length to the result.
56
57     :param index: 0 based tile number (0 is leftmost or topmost)
58     :param length: tile width or height
59     :param margin: space between paper border and first tile
60     :param spacing: space between tiles
61     :return: edge
62     """
63     return margin + index * (length + spacing)
64
65
66 def line(img: Image, pos: int, axis: int):
67     """Creates a horizontal (axis == 0) or vertical (axis == 1) line over the whole
68     width or height of the image.
69
70     :param img: Image that is modified inline.
71     :param pos: Position in pixel from left or top (0 based)
72     :param axis: 0 means horizontal, 1 means vertical
73     """
74     draw = ImageDraw.ImageDraw(img)
75     color = 'black'
76     if axis == 0:
77         draw.line(((0, pos), (img.width, pos)), fill=color)
78     else:
79         draw.line(((pos, 0), (pos, img.height)), fill=color)
80
81
82 def cut_lines(img: Image, count: int, length: int, margin: int, spacing: int, axis: int):
83     """Paint cut lines for the specified amount of tiles.
84     The function can either be used for horizontal or vertical lines.
85
86     :param img: image that is modified (in place)
87     :param count: number of tiles
88     :param length: width or height of tile (width, height) in pixel.
89     :param margin: space between paper border and first tile in pixel.
90     :param spacing: space between tiles in pixel.
91     :param axis: 0 means horizontal lines, 1 means vertical lines
92     """
93     for i in range(count):
94         pos = tile_edge(i, length, margin, spacing)
95         line(img, pos, axis)
96         line(img, pos + length, axis)
97
98
99 def cut_lines_xy(img: Image, columns: int, rows: int, tile_size: Tuple[int, int], margin: int, spacing: int):
100     """Paint cut lines for the specified amount of tiles.
101
102     :param img: image that should be modified.
103     :param columns: number of tiles in horizontal direction.
104     :param rows: number of tiles in vertical direction.
105     :param tile_size: tuple of tile (width, height) in pixel.
106     :param margin: space between paper border and first tile in pixel.
107     :param spacing: space between tiles in pixel.
108     """
109     cut_lines(img, columns, tile_size[0], margin, spacing, 1)
110     cut_lines(img, rows, tile_size[1], margin, spacing, 0)
111
112
113 def halo(paper: Image, columns: int, rows: int, tile_size: Tuple[int, int], halo: int, margin: int, spacing: int):
114     """Creates a tiled "halos" around the yet-to-be-drawn passport tiles to that cut lines don't touch them.
115
116     :param paper: image that should be modified.
117     :param columns: number of tiles in horizontal direction.
118     :param rows: number of tiles in vertical direction.
119     :param tile_size: the (width, height) tuple of the tile without halo
120     :param halo: additional white pixels around the tiles in each direction.
121     :param margin: space between paper border and first tile in pixel.
122     :param spacing: space between tiles in pixel.
123     """
124     halo_tile_size = tuple(size + 2 * halo for size in tile_size)
125     halo_tile = Image.new('RGB', halo_tile_size, 'white')
126     tile(paper, halo_tile, columns, rows, margin - halo, spacing - 2 * halo)
127
128
129 def tile(paper: Image, tile: Image, columns: int, rows: int, margin: int, spacing: int):
130     """On the given "paper" create a grid of a tile with the given number of columns and rows.
131
132     :param paper: image that should be modified
133     :param tile: image that should be duplicated
134     :param columns: number of grid columns
135     :param rows: number of grid rows
136     :param margin: space between paper border and first tile in pixel.
137     :param spacing: space between tiles in pixel.
138     """
139     for c in range(columns):
140         left = tile_edge(c, tile.width, margin, spacing)
141         for r in range(rows):
142             top = tile_edge(r, tile.height, margin, spacing)
143             paper.paste(tile, (left, top))
144
145
146 def downsample_large(img: Image, max_dpi: Optional[int]) -> Image:
147     """Downsamples the image to max_dpi (keeping dimensions) if (a) max_dpi is not None
148     and (b) the resolution is larger than max_dpi."""
149     if max_dpi is None:
150         return img
151     dpi = img.info['dpi'][0]
152     if dpi <= max_dpi:
153         return img
154     width, height = img.size
155     width = int(round(width * max_dpi / dpi))
156     height = int(round(height * max_dpi / dpi))
157     img = img.resize((width, height))
158     img.info['dpi'] = (max_dpi, max_dpi)
159     return img
160
161
162 def process(paper_width_mm: float, paper_height_mm: float, paper_margin_mm: float,
163          photo_width_mm: float, photo_height_mm: float, photo_spacing_mm: float,
164          fpr: float,
165          max_dpi: Optional[int],
166          bbox: Tuple[int,int,int,int],
167          source_image: Image) -> Image:
168     """
169     :param fpr: face to picture ratio, e.g. 2/3
170     """
171     passport_tile = make_passport(source_image, bbox, fpr, photo_width_mm, photo_height_mm)
172     dpi = passport_tile.info['dpi'][0]
173     paper = Image.new('RGB', (mm_to_pixel(paper_width_mm, dpi), mm_to_pixel(paper_height_mm, dpi)), 'white')
174     paper.info['dpi'] = (dpi, dpi)
175     margin = mm_to_pixel(paper_margin_mm, dpi)
176     spacing = mm_to_pixel(photo_spacing_mm, dpi)
177     columns = num_tiles(paper.width, passport_tile.width, margin, spacing)
178     rows = num_tiles(paper.height, passport_tile.height, margin, spacing)
179     cut_lines_xy(paper, columns, rows, passport_tile.size, margin, spacing)
180     halo(paper, columns, rows, passport_tile.size, 1, margin, spacing)
181     tile(paper, passport_tile, columns, rows, margin, spacing)
182     return downsample_large(paper, max_dpi)
183
184
185 def main(paper_width_mm: float, paper_height_mm: float, paper_margin_mm: float,
186          photo_width_mm: float, photo_height_mm: float, photo_spacing_mm: float,
187          fpr: float,
188          max_dpi: Optional[int],
189          bbox: Tuple[int,int,int,int],
190          source_image: str, dest_image: str):
191     """
192     :param fpr: face to picture ratio, e.g. 2/3
193     """
194     orig_photo = Image.open(source_image)
195     paper = process(paper_width_mm, paper_height_mm, paper_margin_mm,
196             photo_width_mm, photo_height_mm, photo_spacing_mm,
197             fpr, max_dpi, bbox, orig_photo)
198     paper.save(dest_image, dpi=paper.info['dpi'])
199
200
201 if __name__ == '__main__':
202     description = 'Convert image with alpha mask marking the face to passport image.'
203     parser = argparse.ArgumentParser(description=description)
204     parser.add_argument('--paper-width', type=float, metavar='mm', default=150., help='paper width in mm (default: 150)')
205     parser.add_argument('--paper-height', type=float, metavar='mm', default=100., help='paper height in mm (default: 100)')
206     parser.add_argument('--paper-margin', type=float, metavar='mm', default=4., help='paper margin in mm (default: 4)')
207     parser.add_argument('--photo-width', type=float, metavar='mm', default=35., help='passport photo width in mm (default: 35)')
208     parser.add_argument('--photo-height', type=float, metavar='mm', default=45., help='passport photo height in mm (default: 45)')
209     parser.add_argument('--photo-spacing', type=float, metavar='mm', default=0., help='space between passport photos in mm (default: 0)')
210     parser.add_argument('--fpr', type=float, default=0.75, help='face to picture ratio')
211     parser.add_argument('--max-dpi', type=int, metavar='dpi', help='if given, scale the final image down to dpi if resultion is larger')
212     parser.add_argument('left', type=int, help='number of pixels from left image border to left face border (inclusive)')
213     parser.add_argument('top', type=int, help='number of pixels from top image border to top face border (inclusive)')
214     parser.add_argument('right', type=int, help='number of pixels from left image border to right face border (exclusive)')
215     parser.add_argument('bottom', type=int, help='number of pixels from top image border to bottom face border (exclusive)')
216     parser.add_argument('source', help='sourse image')
217     parser.add_argument('dest', help='destination image')
218     args = parser.parse_args()
219     main(args.paper_width, args.paper_height, args.paper_margin, args.photo_width, args.photo_height,
220          args.photo_spacing, args.fpr, args.max_dpi,
221          (args.left, args.top, args.right, args.bottom),
222          args.source, args.dest)
223