43501088cda1a6b1bb112b56f2d715443646c5af
[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          max_dpi: Optional[int],
165          bbox: Tuple[int,int,int,int],
166          source_image: Image) -> Image:
167     passport_tile = make_passport(source_image, bbox, 0.75, photo_width_mm, photo_height_mm)
168     dpi = passport_tile.info['dpi'][0]
169     paper = Image.new('RGB', (mm_to_pixel(paper_width_mm, dpi), mm_to_pixel(paper_height_mm, dpi)), 'white')
170     paper.info['dpi'] = (dpi, dpi)
171     margin = mm_to_pixel(paper_margin_mm, dpi)
172     spacing = mm_to_pixel(photo_spacing_mm, dpi)
173     columns = num_tiles(paper.width, passport_tile.width, margin, spacing)
174     rows = num_tiles(paper.height, passport_tile.height, margin, spacing)
175     cut_lines_xy(paper, columns, rows, passport_tile.size, margin, spacing)
176     halo(paper, columns, rows, passport_tile.size, 1, margin, spacing)
177     tile(paper, passport_tile, columns, rows, margin, spacing)
178     return downsample_large(paper, max_dpi)
179
180
181 def main(paper_width_mm: float, paper_height_mm: float, paper_margin_mm: float,
182          photo_width_mm: float, photo_height_mm: float, photo_spacing_mm: float,
183          max_dpi: Optional[int],
184          bbox: Tuple[int,int,int,int],
185          source_image: str, dest_image: str):
186     orig_photo = Image.open(source_image)
187     paper = process(paper_width_mm, paper_height_mm, paper_margin_mm,
188             photo_width_mm, photo_height_mm, photo_spacing_mm,
189             max_dpi, bbox, orig_photo)
190     paper.save(dest_image, dpi=paper.info['dpi'])
191
192
193 if __name__ == '__main__':
194     description = 'Convert image with alpha mask marking the face to passport image.'
195     parser = argparse.ArgumentParser(description=description)
196     parser.add_argument('--paper-width', type=float, metavar='mm', default=150., help='paper width in mm (default: 150)')
197     parser.add_argument('--paper-height', type=float, metavar='mm', default=100., help='paper height in mm (default: 100)')
198     parser.add_argument('--paper-margin', type=float, metavar='mm', default=4., help='paper margin in mm (default: 4)')
199     parser.add_argument('--photo-width', type=float, metavar='mm', default=35., help='passport photo width in mm (default: 35)')
200     parser.add_argument('--photo-height', type=float, metavar='mm', default=45., help='passport photo height in mm (default: 45)')
201     parser.add_argument('--photo-spacing', type=float, metavar='mm', default=0., help='space between passport photos in mm (default: 0)')
202     parser.add_argument('--max-dpi', type=int, metavar='dpi', help='if given, scale the final image down to dpi if resultion is larger')
203     parser.add_argument('left', type=int, help='number of pixels from left image border to left face border (inclusive)')
204     parser.add_argument('top', type=int, help='number of pixels from top image border to top face border (inclusive)')
205     parser.add_argument('right', type=int, help='number of pixels from left image border to right face border (exclusive)')
206     parser.add_argument('bottom', type=int, help='number of pixels from top image border to bottom face border (exclusive)')
207     parser.add_argument('source', help='sourse image')
208     parser.add_argument('dest', help='destination image')
209     args = parser.parse_args()
210     main(args.paper_width, args.paper_height, args.paper_margin, args.photo_width, args.photo_height,
211          args.photo_spacing, args.max_dpi,
212          (args.left, args.top, args.right, args.bottom),
213          args.source, args.dest)
214