thomaseding commited on
Commit
c1b91e0
·
1 Parent(s): f99d377

Add mapped_downscale.py

Browse files
Files changed (1) hide show
  1. mapped_downscale.py +277 -0
mapped_downscale.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import sys
3
+
4
+ from PIL import Image
5
+ from typing import List, Optional, Tuple
6
+
7
+
8
+ Pos = Tuple[int, int]
9
+ Dim = Tuple[int, int]
10
+
11
+
12
+ class Box:
13
+ def __init__(self, min: Pos, max: Pos) -> None:
14
+ self._min = min
15
+ self._max = max
16
+
17
+ # inclusive
18
+ def min(self) -> Tuple[int, int]:
19
+ return self._min
20
+
21
+ # inclusive
22
+ def max(self) -> Tuple[int, int]:
23
+ return self._max
24
+
25
+ def width(self) -> int:
26
+ return self._max[0] - self._min[0] + 1
27
+
28
+ def height(self) -> int:
29
+ return self._max[1] - self._min[1] + 1
30
+
31
+ def dimensions(self) -> Tuple[int, int]:
32
+ return (self.width(), self.height())
33
+
34
+ # (left, upper, right, lower)
35
+ def as_tuple(self) -> Tuple[int, int, int, int]:
36
+ return (self._min[0], self._min[1], self._max[0], self._max[1])
37
+
38
+
39
+ class DownBox(Box):
40
+ def __init__(self, min: Pos, max: Pos, down_pos: Pos) -> None:
41
+ super().__init__(min, max)
42
+ self._down_pos = down_pos
43
+
44
+ def down_pos(self) -> Tuple[int, int]:
45
+ return self._down_pos
46
+
47
+
48
+ class ExtractedBoxes:
49
+ def __init__(self, boxes: List[DownBox]) -> None:
50
+ self._boxes = boxes
51
+
52
+ def boxes(self) -> List[DownBox]:
53
+ return self._boxes
54
+
55
+ def down_dimensions(self) -> Dim:
56
+ if len(self._boxes) == 0:
57
+ return (0, 0)
58
+ back = self._boxes[-1]
59
+ down = back.down_pos()
60
+ return (down[0] + 1, down[1] + 1)
61
+
62
+
63
+ def average_box_dimensions(boxes: List[DownBox]) -> Dim:
64
+ assert len(boxes) > 0
65
+ if len(boxes) == 1:
66
+ return boxes[0].dimensions()
67
+ if len(boxes) <= 16:
68
+ # mean
69
+ width = 0
70
+ height = 0
71
+ for box in boxes:
72
+ width += box.width()
73
+ height += box.height()
74
+ return (width // len(boxes), height // len(boxes))
75
+ # median
76
+ widths = [box.width() for box in boxes]
77
+ heights = [box.height() for box in boxes]
78
+ widths.sort()
79
+ heights.sort()
80
+ return (widths[len(widths) // 2], heights[len(heights) // 2])
81
+
82
+
83
+ def get_trimmed(boxes: List[DownBox]) -> Tuple[Box, Box]:
84
+ avg = average_box_dimensions(boxes)
85
+
86
+ outlier_dist = 1
87
+ # threshold = 8
88
+ # if avg[0] > threshold and avg[1] > threshold:
89
+ # outlier_dist = 2
90
+ # threshold = 32
91
+ # if avg[0] > threshold and avg[1] > threshold:
92
+ # outlier_dist = 3
93
+
94
+ def is_outlier(box: DownBox) -> bool:
95
+ dim = box.dimensions()
96
+ if abs(dim[0] - avg[0]) > outlier_dist:
97
+ return True
98
+ if abs(dim[1] - avg[1]) > outlier_dist:
99
+ return True
100
+ return False
101
+
102
+ assert len(boxes) > 0
103
+ front = boxes[0]
104
+ back = boxes[-1]
105
+
106
+ min_out = (0, 0)
107
+ max_out = back.max()
108
+ min_down = (0, 0)
109
+ max_down = back.down_pos()
110
+ if is_outlier(front):
111
+ for i in range(1, len(boxes)):
112
+ if not is_outlier(boxes[i]):
113
+ min_out = boxes[i].min()
114
+ min_down = boxes[i].down_pos()
115
+ break
116
+ if is_outlier(back):
117
+ for i in range(len(boxes) - 2, -1, -1):
118
+ if not is_outlier(boxes[i]):
119
+ max_out = boxes[i].max()
120
+ max_down = boxes[i].down_pos()
121
+ break
122
+ box_out = Box(min_out, max_out)
123
+ box_down = Box(min_down, max_down)
124
+ return (box_out, box_down)
125
+
126
+
127
+ def calc_face_box(control_image: Image.Image, min_pos: Pos) -> Box:
128
+ min_pixel = control_image.getpixel(min_pos)
129
+ width, height = control_image.size
130
+ x = 0
131
+ while min_pos[0] + x < width:
132
+ if control_image.getpixel((min_pos[0] + x, min_pos[1])) != min_pixel:
133
+ break
134
+ x += 1
135
+ y = 0
136
+ while min_pos[1] + y < height:
137
+ if control_image.getpixel((min_pos[0], min_pos[1] + y)) != min_pixel:
138
+ break
139
+ y += 1
140
+ x -= 1
141
+ y -= 1
142
+ assert x > 0
143
+ assert y > 0
144
+ return Box(min_pos, (x + min_pos[0], y + min_pos[1]))
145
+
146
+
147
+ def extract_boxes(control_image: Image.Image) -> ExtractedBoxes:
148
+ width, height = control_image.size
149
+ assert width > 0
150
+ assert height > 0
151
+
152
+ boxes: List[DownBox] = []
153
+ x = 0
154
+ y = 0
155
+ down_x = 0
156
+ down_y = 0
157
+
158
+ while y < height:
159
+ while x < width:
160
+ min_pos = (x, y)
161
+ box = calc_face_box(control_image, min_pos)
162
+ boxes.append(DownBox(box.min(), box.max(), (down_x, down_y)))
163
+ x += box.width()
164
+ down_x += 1
165
+ assert x == width
166
+ box = boxes[-1]
167
+ x = 0
168
+ y += box.height()
169
+ down_x = 0
170
+ down_y += 1
171
+ assert y == height
172
+
173
+ return ExtractedBoxes(boxes)
174
+
175
+
176
+ def downsample_one(input_image: Image.Image, box: Box, sample_radius: Optional[int], downsampler: Image.Resampling) -> Tuple[int, int, int]:
177
+ region = input_image.crop(box.as_tuple())
178
+
179
+ box_width = box.width()
180
+ box_height = box.height()
181
+ box_center_x = box.min()[0] + box_width // 2
182
+ box_center_y = box.min()[1] + box_height // 2
183
+
184
+ if sample_radius is not None:
185
+ radius_x = min(sample_radius, box_width // 2)
186
+ radius_y = min(sample_radius, box_height // 2)
187
+ else:
188
+ radius_x = box_width // 2
189
+ radius_y = box_height // 2
190
+
191
+ cropped_region = region.crop((
192
+ max(0, box_center_x - radius_x - box.min()[0]),
193
+ max(0, box_center_y - radius_y - box.min()[1]),
194
+ min(box_width, box_center_x + radius_x - box.min()[0]),
195
+ min(box_height, box_center_y + radius_y - box.min()[1])
196
+ ))
197
+ assert cropped_region.size[0] >= radius_x and cropped_region.size[1] >= radius_y
198
+ sampled = cropped_region.resize((1, 1), downsampler)
199
+
200
+ rgb_value = sampled.getpixel((0, 0))
201
+ assert isinstance(rgb_value, tuple) and len(rgb_value) == 3
202
+ return rgb_value
203
+
204
+
205
+ class ImageRef:
206
+ def __init__(self, ref: Image.Image) -> None:
207
+ self.ref = ref
208
+
209
+
210
+ def downsample_all(*, input_image: Image.Image, output_image: Optional[ImageRef], down_image: Optional[ImageRef], boxes: List[DownBox], sample_radius: Optional[int], downsampler: Image.Resampling, trim_cropped_edges: bool) -> None:
211
+ assert output_image or down_image
212
+ for box in boxes:
213
+ rgb_value = downsample_one(input_image, box, sample_radius, downsampler)
214
+ solid_color_image = Image.new("RGB", box.dimensions(), rgb_value)
215
+ if output_image:
216
+ output_image.ref.paste(solid_color_image, box.min())
217
+ if down_image:
218
+ down_image.ref.paste(solid_color_image, box.down_pos())
219
+ if trim_cropped_edges:
220
+ o, d = get_trimmed(boxes)
221
+ if output_image:
222
+ output_image.ref = output_image.ref.crop(o.as_tuple())
223
+ if down_image:
224
+ down_image.ref = down_image.ref.crop(d.as_tuple())
225
+
226
+
227
+ def str2bool(value) -> bool:
228
+ if isinstance(value, bool):
229
+ return value
230
+ if value.lower() in ("yes", "true", "t", "y", "1"):
231
+ return True
232
+ elif value.lower() in ("no", "false", "f", "n", "0"):
233
+ return False
234
+ else:
235
+ raise argparse.ArgumentTypeError("Boolean value expected.")
236
+
237
+
238
+ def main(cli_args: List[str]) -> None:
239
+ parser = argparse.ArgumentParser(description="Downsample and rescale image.")
240
+ parser.add_argument("--control", required=True, help="Path to control image.")
241
+ parser.add_argument("--input", required=True, help="Path to input image.")
242
+ parser.add_argument("--output-up", help="Path to save the output image, upscaled to the original size.")
243
+ parser.add_argument("--output-down", help="Path to save the output image, kept at the downsampled size.")
244
+ parser.add_argument("--sample-radius", type=int, default=None, help="Radius for sampling (Manhattan distance).")
245
+ parser.add_argument("--downsampler", choices=["box", "bilinear", "bicubic", "hamming", "lanczos"], default="box", help="Downsampler to use.")
246
+ parser.add_argument("--trim-cropped-edges", type=str2bool, default=False, help="Drop mapped checker grid elements that are cropped in the control image.")
247
+
248
+ args = parser.parse_args(cli_args)
249
+
250
+ control_image = Image.open(args.control).convert("1")
251
+ input_image = Image.open(args.input)
252
+ if control_image.size != input_image.size:
253
+ raise ValueError("Control image and input image must have the same dimensions.")
254
+ downsampler = Image.Resampling[args.downsampler.upper()]
255
+ output_image: Optional[ImageRef] = None
256
+ down_image: Optional[ImageRef] = None
257
+ if not args.output_up and not args.output_down:
258
+ raise ValueError("At least one of --output-up and --output-down must be specified.")
259
+ if args.output_up:
260
+ output_image = ImageRef(Image.new("RGB", input_image.size))
261
+ extracted_boxes = extract_boxes(control_image)
262
+ if args.output_down:
263
+ down_image = ImageRef(Image.new("RGB", extracted_boxes.down_dimensions()))
264
+
265
+ boxes = extracted_boxes.boxes()
266
+
267
+ print(args.trim_cropped_edges)
268
+
269
+ downsample_all(input_image=input_image, output_image=output_image, down_image=down_image, boxes=boxes, sample_radius=args.sample_radius, downsampler=downsampler, trim_cropped_edges=args.trim_cropped_edges)
270
+ if output_image:
271
+ output_image.ref.save(args.output_up)
272
+ if down_image:
273
+ down_image.ref.save(args.output_down)
274
+
275
+
276
+ if __name__ == "__main__":
277
+ main(sys.argv[1:])