summaryrefslogtreecommitdiff
path: root/bin/photocollage/render.py
diff options
context:
space:
mode:
authorskylar <me@casper>2026-11-01 07:03:26 -0800
committerskylar <me@casper>2026-11-01 07:03:26 -0800
commit070a107873440d62b4519e6d91c2ba739d4b6fa2 (patch)
treeae4e1c3540cdd01b3289ed6f96ec3ea69507f73e /bin/photocollage/render.py
Literate Doom Emacs configuration
config.org tangles to config.el, which loads private/config.el last when it exists. bin/ holds the scripts some sections drive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'bin/photocollage/render.py')
-rw-r--r--bin/photocollage/render.py313
1 files changed, 313 insertions, 0 deletions
diff --git a/bin/photocollage/render.py b/bin/photocollage/render.py
new file mode 100644
index 0000000..f9b9024
--- /dev/null
+++ b/bin/photocollage/render.py
@@ -0,0 +1,313 @@
+# Copyright (C) 2014 Adrien Vergé
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+import random
+from threading import Thread
+import time
+
+import PIL.Image
+import PIL.ImageDraw
+import PIL.ImageFile
+
+from photocollage.collage import Photo
+
+
+QUALITY_SKEL = 0
+QUALITY_FAST = 1
+QUALITY_BEST = 2
+
+
+# Try to continue even if the input file is corrupted.
+# See issue at https://github.com/adrienverge/PhotoCollage/issues/65
+PIL.ImageFile.LOAD_TRUNCATED_IMAGES = True
+
+
+class PIL_SUPPORTED_EXTS:
+ """File extensions supported by PIL
+
+ Compiled from:
+ - http://pillow.readthedocs.org/en/2.3.0/handbook/image-file-formats.html
+ - https://github.com/python-imaging/Pillow/blob/master/PIL/*ImagePlugin.py
+
+ """
+ RW = {
+ "BMP": ("bmp",),
+ # "EPS": ("ps", "eps",), # doesn't seem to work
+ "GIF": ("gif",),
+ "IM": ("im",),
+ "JPEG": ("jfif", "jpe", "jpg", "jpeg",),
+ "MSP": ("msp",),
+ "PCX": ("pcx",),
+ "PNG": ("png",),
+ "PPM": ("pbm", "pgm", "ppm",),
+ "TGA": ("tga",),
+ "TIFF": ("tif", "tiff",),
+ "WebP": ("webp",),
+ "XBM": ("xbm",),
+ }
+ RO = {
+ "CUR": ("cur",),
+ "DCX": ("dcx",),
+ "FLI": ("fli", "flc",),
+ "FPX": ("fpx",),
+ "GBR": ("gbr",),
+ "ICO": ("ico",),
+ "IPTC/NAA": ("iim",),
+ "PCD": ("pcd",),
+ "PSD": ("psd",),
+ "SGI": ("bw", "rgb", "rgba", "sgi",),
+ "XPM": ("xpm",),
+ }
+ WO = {
+ # "PALM": ("palm",), # doesn't seem to work
+ # "PDF": ("pdf",), # doesn't seem to work
+ }
+
+
+def random_color():
+ r = random.randrange(256)
+ g = random.randrange(256)
+ b = random.randrange(256)
+ if r + g + b > 0.7 * 3 * 256:
+ r -= 50
+ g -= 50
+ b -= 50
+ return (r, g, b)
+
+
+class BadPhoto(Exception):
+ def __init__(self, photoname):
+ self.photoname = photoname
+
+
+def build_photolist(filelist):
+ ret = []
+
+ for name in filelist:
+ try:
+ img = PIL.Image.open(name)
+ except OSError:
+ raise BadPhoto(name)
+ w, h = img.size
+
+ orientation = 0
+ try:
+ exif = img._getexif()
+ if 274 in exif: # orientation tag
+ orientation = exif[274]
+ if orientation == 6 or orientation == 8:
+ w, h = h, w
+ except Exception:
+ pass
+
+ ret.append(Photo(name, w, h, orientation))
+ return ret
+
+
+cache = {}
+
+
+class RenderingTask(Thread):
+ """Execution thread to do the actual poster rendering
+
+ Image computation is a heavy task, that can take several seconds. During
+ this, the program might be unresponding. To avoid this, rendering is done
+ is a separated thread.
+
+ """
+ def __init__(self, page, border_width=0.01, border_color=(0, 0, 0),
+ quality=QUALITY_FAST, output_file=None,
+ on_update=None, on_complete=None, on_fail=None):
+ super().__init__()
+
+ self.page = page
+ self.border_width = border_width
+ self.border_color = border_color
+ self.quality = quality
+
+ self.output_file = output_file
+
+ self.on_update = on_update
+ self.on_complete = on_complete
+ self.on_fail = on_fail
+
+ self.canceled = False
+
+ def abort(self):
+ self.canceled = True
+
+ def draw_skeleton(self, canvas):
+ for col in self.page.cols:
+ for c in col.cells:
+ if c.is_extension():
+ continue
+ color = random_color()
+ x, y, w, h = c.content_coords()
+ xy = (x, y)
+ xY = (x, y + h - 1)
+ Xy = (x + w - 1, y)
+ XY = (x + w - 1, y + h - 1)
+
+ draw = PIL.ImageDraw.Draw(canvas)
+ draw.line(xy + Xy, fill=color)
+ draw.line(xy + xY, fill=color)
+ draw.line(xY + XY, fill=color)
+ draw.line(Xy + XY, fill=color)
+ draw.line(xy + XY, fill=color)
+ draw.line(xY + Xy, fill=color)
+ return canvas
+
+ def draw_borders(self, canvas):
+ if self.border_width == 0:
+ return
+
+ W = self.page.w - 1
+ H = self.page.h - 1
+ border = self.border_width - 1
+ color = self.border_color
+
+ draw = PIL.ImageDraw.Draw(canvas)
+ draw.rectangle((0, 0) + (border, H), color)
+ draw.rectangle((W - border, 0) + (W, H), color)
+ draw.rectangle((0, 0) + (W, border), color)
+ draw.rectangle((0, H - border) + (W, H), color)
+
+ for col in self.page.cols:
+ # Draw horizontal borders
+ for c in col.cells[1:]:
+ xy = (col.x, c.y - border / 2)
+ XY = (col.x + col.w, c.y + border / 2)
+ draw.rectangle(xy + XY, color)
+ # Draw vertical borders
+ if col.x > 0:
+ for c in col.cells:
+ if not c.is_extension():
+ xy = (col.x - border / 2, c.y)
+ XY = (col.x + border / 2, c.y + c.h)
+ draw.rectangle(xy + XY, color)
+ return canvas
+
+ def resize_photo(self, cell, use_cache=False):
+ # If a thumbnail is already in cache, let's use it. But only if it is
+ # bigger than what we need, because we don't want to lose quality.
+ if (use_cache and cell.photo.filename in cache and
+ cache[cell.photo.filename].size[0] >= int(round(cell.w)) and
+ cache[cell.photo.filename].size[1] >= int(round(cell.h))):
+ img = cache[cell.photo.filename].copy()
+ else:
+ img = PIL.Image.open(cell.photo.filename)
+
+ # Rotate image is EXIF says so
+ if cell.photo.orientation == 3:
+ img = img.rotate(180, expand=True)
+ elif cell.photo.orientation == 6:
+ img = img.rotate(270, expand=True)
+ elif cell.photo.orientation == 8:
+ img = img.rotate(90, expand=True)
+
+ if self.quality == QUALITY_FAST:
+ method = PIL.Image.NEAREST
+ else:
+ method = PIL.Image.LANCZOS # renamed from ANTIALIAS in Pillow 10
+
+ shape = img.size[0] * cell.h - img.size[1] * cell.w
+ if shape > 0: # image is too thick
+ img = img.resize((int(round(cell.h * img.size[0] / img.size[1])),
+ int(round(cell.h))), method)
+ elif shape < 0: # image is too tall
+ img = img.resize((int(round(cell.w)),
+ int(round(cell.w * img.size[1] / img.size[0]))),
+ method)
+ else:
+ img = img.resize((int(round(cell.w)), int(round(cell.h))), method)
+
+ # Save this new image to cache (if it is larger than the previous one)
+ if (use_cache and (cell.photo.filename not in cache or
+ cache[cell.photo.filename].size[0] < img.size[0])):
+ cache[cell.photo.filename] = img
+
+ if shape > 0: # image is too thick
+ width_to_crop = img.size[0] - cell.w
+ img = img.crop((
+ int(round(width_to_crop * cell.photo.offset_w)),
+ 0,
+ int(round(img.size[0] - width_to_crop *
+ (1 - cell.photo.offset_w))),
+ int(round(cell.h))
+ ))
+ elif shape < 0: # image is too tall
+ height_to_crop = img.size[1] - cell.h
+ img = img.crop((
+ 0,
+ int(round(height_to_crop * cell.photo.offset_h)),
+ int(round(cell.w)),
+ int(round(img.size[1] - height_to_crop *
+ (1 - cell.photo.offset_h)))
+ ))
+
+ return img
+
+ def paste_photo(self, canvas, cell, img):
+ canvas.paste(img, (int(round(cell.x)), int(round(cell.y))))
+ return canvas
+
+ def run(self):
+ try:
+ canvas = PIL.Image.new(
+ "RGB", (int(self.page.w), int(self.page.h)), "white")
+
+ self.draw_skeleton(canvas)
+ self.draw_borders(canvas)
+
+ if self.quality != QUALITY_SKEL:
+ n = sum([len([cell for cell in col.cells if not
+ cell.is_extension()]) for col in self.page.cols])
+ i = 0.0
+ if self.on_update:
+ self.on_update(canvas, 0.0)
+ last_update = time.time()
+
+ for col in self.page.cols:
+ for c in col.cells:
+ if self.canceled: # someone clicked "abort"
+ return
+
+ if c.is_extension():
+ continue
+
+ img = self.resize_photo(c, use_cache=True)
+ self.paste_photo(canvas, c, img)
+
+ # Only needed for interactive rendering
+ if self.on_update:
+ self.draw_borders(canvas)
+
+ i += 1
+ now = time.time()
+ if self.on_update and now > last_update + 0.1:
+ self.on_update(canvas, i / n)
+ last_update = now
+
+ self.draw_borders(canvas)
+
+ if self.output_file:
+ canvas.save(self.output_file)
+
+ if self.on_complete:
+ self.on_complete(canvas)
+ except Exception as e:
+ if self.on_fail:
+ self.on_fail(e)