image_operations.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. import inspect
  2. from wagtail.images.exceptions import InvalidFilterSpecError
  3. from wagtail.images.rect import Rect, Vector
  4. from wagtail.images.utils import parse_color_string
  5. class Operation:
  6. def __init__(self, method, *args):
  7. self.method = method
  8. self.args = args
  9. # Check arguments
  10. try:
  11. inspect.getcallargs(self.construct, *args)
  12. except TypeError as e:
  13. raise InvalidFilterSpecError(e)
  14. # Call construct
  15. try:
  16. self.construct(*args)
  17. except ValueError as e:
  18. raise InvalidFilterSpecError(e)
  19. def construct(self, *args):
  20. raise NotImplementedError
  21. # Transforms
  22. class ImageTransform:
  23. """
  24. Tracks transformations that are performed on an image.
  25. This allows multiple transforms to be processed in a single operation and also
  26. accumulates the operations into a single scale/offset which can be used for
  27. features such as transforming the focal point of the image.
  28. """
  29. def __init__(self, size, image_is_svg=False):
  30. self._check_size(size, allow_floating_point=image_is_svg)
  31. self.image_is_svg = image_is_svg
  32. self.size = size
  33. self.scale = (1.0, 1.0)
  34. self.offset = (0.0, 0.0)
  35. def clone(self):
  36. clone = ImageTransform(self.size, self.image_is_svg)
  37. clone.scale = self.scale
  38. clone.offset = self.offset
  39. return clone
  40. def resize(self, size):
  41. """
  42. Change the image size, stretching the transform to make it fit the new size.
  43. """
  44. self._check_size(size, allow_floating_point=self.image_is_svg)
  45. clone = self.clone()
  46. clone.scale = (
  47. clone.scale[0] * size[0] / self.size[0],
  48. clone.scale[1] * size[1] / self.size[1],
  49. )
  50. clone.size = size
  51. return clone
  52. def crop(self, rect):
  53. """
  54. Crop the image to the specified rect.
  55. """
  56. self._check_size(tuple(rect.size), allow_floating_point=self.image_is_svg)
  57. # Transform the image so the top left of the rect is at (0, 0), then set the size
  58. clone = self.clone()
  59. clone.offset = (
  60. clone.offset[0] - rect.left / self.scale[0],
  61. clone.offset[1] - rect.top / self.scale[1],
  62. )
  63. clone.size = tuple(rect.size)
  64. return clone
  65. def transform_vector(self, vector):
  66. """
  67. Transforms the given vector into the coordinate space of the final image.
  68. Use this to find out where a point on the source image would end up in the
  69. final image after cropping/resizing has been performed.
  70. Returns a new vector.
  71. """
  72. return Vector(
  73. (vector.x + self.offset[0]) * self.scale[0],
  74. (vector.y + self.offset[1]) * self.scale[1],
  75. )
  76. def untransform_vector(self, vector):
  77. """
  78. Transforms the given vector back to the coordinate space of the source image.
  79. This performs the inverse of `transform_vector`. Use this to find where a point
  80. in the final cropped/resized image originated from in the source image.
  81. Returns a new vector.
  82. """
  83. return Vector(
  84. vector.x / self.scale[0] - self.offset[0],
  85. vector.y / self.scale[1] - self.offset[1],
  86. )
  87. def get_rect(self):
  88. """
  89. Returns a Rect representing the region of the original image to be cropped.
  90. """
  91. return Rect(
  92. -self.offset[0],
  93. -self.offset[1],
  94. -self.offset[0] + self.size[0] / self.scale[0],
  95. -self.offset[1] + self.size[1] / self.scale[1],
  96. )
  97. @staticmethod
  98. def _check_size(size, allow_floating_point=False):
  99. if not isinstance(size, tuple) or len(size) != 2:
  100. raise TypeError("Image size must be a 2-tuple")
  101. if not allow_floating_point and (
  102. int(size[0]) != size[0] or int(size[1]) != size[1]
  103. ):
  104. raise TypeError("Image size must be a 2-tuple of integers")
  105. if size[0] < 1 or size[1] < 1:
  106. raise ValueError("Image width and height must both be 1 or greater")
  107. class TransformOperation(Operation):
  108. def run(self, image, transform):
  109. raise NotImplementedError
  110. class FillOperation(TransformOperation):
  111. vary_fields = (
  112. "focal_point_width",
  113. "focal_point_height",
  114. "focal_point_x",
  115. "focal_point_y",
  116. )
  117. def construct(self, size, *extra):
  118. # Get width and height
  119. width_str, height_str = size.split("x")
  120. self.width = int(width_str)
  121. self.height = int(height_str)
  122. # Crop closeness
  123. self.crop_closeness = 0
  124. for extra_part in extra:
  125. if extra_part.startswith("c"):
  126. self.crop_closeness = int(extra_part[1:])
  127. else:
  128. raise ValueError("Unrecognised filter spec part: %s" % extra_part)
  129. # Divide it by 100 (as it's a percentage)
  130. self.crop_closeness /= 100
  131. # Clamp it
  132. if self.crop_closeness > 1:
  133. self.crop_closeness = 1
  134. def run(self, transform, image):
  135. image_width, image_height = transform.size
  136. focal_point = image.get_focal_point()
  137. # Get crop aspect ratio
  138. crop_aspect_ratio = self.width / self.height
  139. # Get crop max
  140. crop_max_scale = min(image_width, image_height * crop_aspect_ratio)
  141. crop_max_width = crop_max_scale
  142. crop_max_height = crop_max_scale / crop_aspect_ratio
  143. # Initialise crop width and height to max
  144. crop_width = crop_max_width
  145. crop_height = crop_max_height
  146. # Use crop closeness to zoom in
  147. if focal_point is not None:
  148. # Get crop min
  149. crop_min_scale = max(
  150. focal_point.width, focal_point.height * crop_aspect_ratio
  151. )
  152. crop_min_width = crop_min_scale
  153. crop_min_height = crop_min_scale / crop_aspect_ratio
  154. # Sometimes, the focal point may be bigger than the image...
  155. if not crop_min_scale >= crop_max_scale:
  156. # Calculate max crop closeness to prevent upscaling
  157. max_crop_closeness = max(
  158. 1
  159. - (self.width - crop_min_width) / (crop_max_width - crop_min_width),
  160. 1
  161. - (self.height - crop_min_height)
  162. / (crop_max_height - crop_min_height),
  163. )
  164. # Apply max crop closeness
  165. crop_closeness = min(self.crop_closeness, max_crop_closeness)
  166. if 1 >= crop_closeness >= 0:
  167. # Get crop width and height
  168. crop_width = (
  169. crop_max_width
  170. + (crop_min_width - crop_max_width) * crop_closeness
  171. )
  172. crop_height = (
  173. crop_max_height
  174. + (crop_min_height - crop_max_height) * crop_closeness
  175. )
  176. # Find focal point UV
  177. if focal_point is not None:
  178. fp_x, fp_y = focal_point.centroid
  179. else:
  180. # Fall back to positioning in the centre
  181. fp_x = image_width / 2
  182. fp_y = image_height / 2
  183. fp_u = fp_x / image_width
  184. fp_v = fp_y / image_height
  185. # Position crop box based on focal point UV
  186. crop_x = fp_x - (fp_u - 0.5) * crop_width
  187. crop_y = fp_y - (fp_v - 0.5) * crop_height
  188. # Convert crop box into rect
  189. rect = Rect.from_point(crop_x, crop_y, crop_width, crop_height)
  190. # Make sure the entire focal point is in the crop box
  191. if focal_point is not None:
  192. rect = rect.move_to_cover(focal_point)
  193. # Don't allow the crop box to go over the image boundary
  194. rect = rect.move_to_clamp(Rect(0, 0, image_width, image_height))
  195. # Crop!
  196. transform = transform.crop(rect.round())
  197. # Get scale for resizing
  198. # The scale should be the same for both the horizontal and
  199. # vertical axes
  200. aftercrop_width, aftercrop_height = transform.size
  201. scale = self.width / aftercrop_width
  202. # Only resize if the image is too big
  203. if scale < 1.0:
  204. # Resize!
  205. transform = transform.resize((self.width, self.height))
  206. return transform
  207. class MinMaxOperation(TransformOperation):
  208. def construct(self, size):
  209. # Get width and height
  210. width_str, height_str = size.split("x")
  211. self.width = int(width_str)
  212. self.height = int(height_str)
  213. def run(self, transform, image):
  214. image_width, image_height = transform.size
  215. horz_scale = self.width / image_width
  216. vert_scale = self.height / image_height
  217. if self.method == "min":
  218. if image_width <= self.width or image_height <= self.height:
  219. return transform
  220. if horz_scale > vert_scale:
  221. width = self.width
  222. height = int(image_height * horz_scale)
  223. else:
  224. width = int(image_width * vert_scale)
  225. height = self.height
  226. elif self.method == "max":
  227. if image_width <= self.width and image_height <= self.height:
  228. return transform
  229. if horz_scale < vert_scale:
  230. width = self.width
  231. height = int(image_height * horz_scale)
  232. else:
  233. width = int(image_width * vert_scale)
  234. height = self.height
  235. else:
  236. # Unknown method
  237. return transform
  238. # prevent zero width or height, it causes a ValueError on transform.resize
  239. width = width if width > 0 else 1
  240. height = height if height > 0 else 1
  241. return transform.resize((width, height))
  242. class WidthHeightOperation(TransformOperation):
  243. def construct(self, size):
  244. self.size = int(size)
  245. def run(self, transform, image):
  246. image_width, image_height = transform.size
  247. if self.method == "width":
  248. if image_width <= self.size:
  249. return transform
  250. scale = self.size / image_width
  251. width = self.size
  252. height = int(image_height * scale)
  253. elif self.method == "height":
  254. if image_height <= self.size:
  255. return transform
  256. scale = self.size / image_height
  257. width = int(image_width * scale)
  258. height = self.size
  259. else:
  260. # Unknown method
  261. return transform
  262. # prevent zero width or height, it causes a ValueError on transform.resize
  263. width = width if width > 0 else 1
  264. height = height if height > 0 else 1
  265. return transform.resize((width, height))
  266. class ScaleOperation(TransformOperation):
  267. def construct(self, percent):
  268. self.percent = float(percent)
  269. def run(self, transform, image):
  270. image_width, image_height = transform.size
  271. scale = self.percent / 100
  272. width = int(image_width * scale)
  273. height = int(image_height * scale)
  274. # prevent zero width or height, it causes a ValueError on transform.resize
  275. width = width if width > 0 else 1
  276. height = height if height > 0 else 1
  277. return transform.resize((width, height))
  278. # Filters
  279. class FilterOperation(Operation):
  280. def run(self, willow, image, env):
  281. raise NotImplementedError
  282. class DoNothingOperation(FilterOperation):
  283. def construct(self):
  284. pass
  285. def run(self, willow, image, env):
  286. return willow
  287. class JPEGQualityOperation(FilterOperation):
  288. def construct(self, quality):
  289. self.quality = int(quality)
  290. if self.quality > 100:
  291. raise ValueError("JPEG quality must not be higher than 100")
  292. def run(self, willow, image, env):
  293. env["jpeg-quality"] = self.quality
  294. class AvifQualityOperation(FilterOperation):
  295. def construct(self, quality):
  296. self.quality = int(quality)
  297. if self.quality > 100:
  298. raise ValueError("AVIF quality must not be higher than 100")
  299. def run(self, willow, image, env):
  300. env["avif-quality"] = self.quality
  301. class WebPQualityOperation(FilterOperation):
  302. def construct(self, quality):
  303. self.quality = int(quality)
  304. if self.quality > 100:
  305. raise ValueError("WebP quality must not be higher than 100")
  306. def run(self, willow, image, env):
  307. env["webp-quality"] = self.quality
  308. class FormatOperation(FilterOperation):
  309. supported_formats = ["jpeg", "png", "gif", "webp", "avif", "ico", "heic"]
  310. def construct(self, format, *options):
  311. self.format = format
  312. self.options = options
  313. if self.format not in self.supported_formats:
  314. raise ValueError(
  315. f"Format must be one of: {', '.join(self.supported_formats)}. Got: {self.format}"
  316. )
  317. def run(self, willow, image, env):
  318. env["output-format"] = self.format
  319. env["output-format-options"] = self.options
  320. class BackgroundColorOperation(FilterOperation):
  321. def construct(self, color_string):
  322. self.color = parse_color_string(color_string)
  323. def run(self, willow, image, env):
  324. return willow.set_background_color_rgb(self.color)