From 070a107873440d62b4519e6d91c2ba739d4b6fa2 Mon Sep 17 00:00:00 2001 From: skylar Date: Sun, 1 Nov 2026 07:03:26 -0800 Subject: 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 --- bin/photocollage/__init__.py | 0 bin/photocollage/collage.py | 568 +++++++++++++++++++++++++++++++++++++++++++ bin/photocollage/render.py | 313 ++++++++++++++++++++++++ 3 files changed, 881 insertions(+) create mode 100644 bin/photocollage/__init__.py create mode 100644 bin/photocollage/collage.py create mode 100644 bin/photocollage/render.py (limited to 'bin/photocollage') diff --git a/bin/photocollage/__init__.py b/bin/photocollage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bin/photocollage/collage.py b/bin/photocollage/collage.py new file mode 100644 index 0000000..134735b --- /dev/null +++ b/bin/photocollage/collage.py @@ -0,0 +1,568 @@ +# 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 + +""" +Summary of collage objects: + +---------------------- +| | +| | +| Page | The "Page" object represents the whole page that +| | will give the final assembled image. +| | +---------------------- + +---------------------- +| | | | +| |Column| | +| | |Column| A page is divided into columns. +|Column| | | +| | | | +---------------------- + +---------------------- +| Cell | Cell | Each column contains cells. When a +|------| x--|----- CellExtent is located in several columns, +| Cell |-------------| its "extended" flag is set, and a +|------| Cell | | CellExtent object is added to the +| Cell | | Cell | column on the right to reserve the +---------------------- place. + + ,------> Photo + / ,---------> Photo +---------------------- +| | | Each cell is associated +|------| | to a photo. +| |-------------| +|------| | |----> Photo +| | | | +---------------------- + `----------> Photo + +The layout placing process is divided in three phases. + +Phase A: Fill columns with photos. + Photos are added to columns, one by one, until there are no more photos. + Each new photo is put in the smallest column, so as to have balanced + columns. If two columns have approximately the same height, a photo can be + "extended" to fit two columns. In this case, the "Cell" object is put in + the first column, and the second column takes a "CellExtent" object to + reserve the taken space. + +Phase B: Set all columns to same height. + A global common height is computed for all columns, and every of them is + stressed or extended to this common length. This can result in a decrease + or increase in columns' width. + +Phase C: Adapt columns' width. + Since cells in a column may have different widths, each column width is set + to the smallest width amongst its images. + +""" + + +class Photo: + def __init__(self, filename, w, h, orientation=0): + self.filename = filename + self.w = w + self.h = h + self.orientation = orientation + self.offset_w = 0.5 + self.offset_h = 0.5 + + @property + def ratio(self): + return float(self.h) / float(self.w) + + def move(self, x, y): + self.offset_w = self.calculate_new_offset(self.offset_w, x) + self.offset_h = self.calculate_new_offset(self.offset_h, y) + + @staticmethod + def calculate_new_offset(offset, value): + new_offset = offset + value + if new_offset < 0: + new_offset = 0 + elif new_offset > 1: + new_offset = 1 + return new_offset + + +class Cell: + """Represents a cell in a column + + Properties: + <- x -><- w -> + ---------------------- ^ + | | | |y + |------| | | + | |-------------| v + |------| Cell | | ^ + | | | | |h + ---------------------- v + + """ + def __init__(self, parents, photo): + self.parents = parents + self.photo = photo + self.extent = None + self.h = self.w * self.wanted_ratio + + def __repr__(self): + """Representation of the cell in ASCII art""" + end = "]" + if self.extent is not None: + end = "--" + return "[%d %d%s" % (self.w, self.h, end) + + @property + def x(self): + return self.parents[0].x + + @property + def y(self): + """Returns the cell's y coordinate + + It assumes that the cell is in a single column, so it is the previous + cell's y + h. + + """ + prev = None + for c in self.parents[0].cells: + if self is c: + if prev: + return prev.y + prev.h + return 0 + prev = c + + @property + def w(self): + return sum(c.w for c in self.parents) + + @property + def ratio(self): + return self.h / self.w + + @property + def wanted_ratio(self): + return self.photo.ratio + + def scale(self, alpha): + self.h *= alpha + + def is_extended(self): + return hasattr(self, 'extent') and self.extent is not None + + def is_extension(self): + return isinstance(self, CellExtent) + + def content_coords(self): + """Returns the coordinates of the contained image + + These are computed in order not to loose space, so the content area + will always be greater than the cell itself. It is the space taken by + the contained image if it wasn't cropped. + + """ + # If the contained image is too thick to fit + if self.wanted_ratio < self.ratio: + h = self.h + w = self.h / self.wanted_ratio + y = self.y + x = self.x - (w - self.w) / 2.0 + # If the contained image is too tall to fit + elif self.wanted_ratio > self.ratio: + w = self.w + h = self.w * self.wanted_ratio + x = self.x + y = self.y - (h - self.h) / 2.0 + else: + w = self.w + h = self.h + x = self.x + y = self.y + return x, y, w, h + + def top_neighbor(self): + """Returns the cell above this one""" + prev = None + for c in self.parents[0].cells: + if self is c: + return prev + prev = c + + def bottom_neighbor(self): + """Returns the cell below this one""" + prev = None + for c in reversed(self.parents[0].cells): + if self is c: + return prev + prev = c + + +class CellExtent(Cell): + def __init__(self, cell): + self.origin = cell + self.origin.extent = self + + def __repr__(self): + """Representation of the cell in ASCII art""" + return "------]" + + @property + def parents(self): + return (self.origin.parents[1],) + + @property + def photo(self): + return self.origin.photo + + @property + def y(self): + return self.origin.y + + @property + def h(self): + return self.origin.h + + def scale(self, alpha): + pass + + +class Column: + """Represents a column in a page + + Properties: + <----- x ----><-- w -> + ---------------------- ^ + | | | | | + | | | | + | | |Column| h + | | | | + -------| | | | + | |------- v + -------- + + """ + def __init__(self, parent, w): + self.parent = parent + self.cells = [] + self.w = w + + def __repr__(self): + """Representation of the column in ASCII art""" + return "\n".join(c.__repr__() for c in self.cells) + + @property + def h(self): + """Returns the column's total height + + This is not simply the sum of its cells heights, because there can be + empty spaces between cells. + + """ + if not self.cells: + return 0 + return self.cells[-1].y + self.cells[-1].h + + @property + def x(self): + x = 0 + for c in self.parent.cols: + if self is c: + break + x += c.w + return x + + def scale(self, alpha): + self.w *= alpha + for c in self.cells: + c.scale(alpha) + + def left_neighbor(self): + """Returns the column on the left of this one""" + prev = None + for c in self.parent.cols: + if self is c: + return prev + prev = c + + def right_neighbor(self): + """Returns the column on the right of this one""" + prev = None + for c in reversed(self.parent.cols): + if self is c: + return prev + prev = c + + def adjust_height(self, target_h): + """Set the column's height to a given value by resizing cells""" + # First, make groups of "movable" cells. Since cell extents are not + # movable, these groups only contain pure cell objects. We only resize + # those groups. + class Group: + def __init__(self, y): + self.y = y + self.h = 0 + self.cells = [] + + groups = [] + groups.append(Group(0)) + for c in self.cells: + # While a cell extent is not reached, keep add cells to the group + if not c.is_extension(): + groups[-1].cells.append(c) + else: + # Close current group and create a new one + groups[-1].h = c.y - groups[-1].y + groups.append(Group(c.y + c.h)) + groups[-1].h = target_h - groups[-1].y + + # Adjust height for each group independently + for group in groups: + if not group.cells: + continue + alpha = group.h / sum(c.h for c in group.cells) + for c in group.cells: + c.h = c.h * alpha + + +class Page: + """Represents a whole page + + Properties: + <-------- w --------> + ---------------------- ^ + | | | + | | + | Page | h + | | + | | | + ---------------------- v + + """ + def __init__(self, w, target_ratio, no_cols): + self.target_ratio = target_ratio + col_w = float(w)/no_cols + self.cols = [] + for i in range(no_cols): + self.cols.append(Column(self, col_w)) + + def __repr__(self): + """Representation of the page in ASCII art + + Returns something like: + [62 52] [125 134-- ------] [62 87] + [62 47] [62 66] [125 132-- [62 45] + [62 46] ------] [62 49] ------] + [62 78] ------] [62 49] [62 45] + [125 102-- ------] [62 49] [62 65] + [125 135-- [62 85] [62 53] + [125 91-- [125 89-- [62 64] + ------] + """ + lines = [] + n = 0 + end = False + while not end: + lines.append("") + end = True + for col in self.cols: + cells = col.__repr__().split("\n") + w = max(len(cell) for cell in cells) + if col != self.cols[-1]: + w += 1 + cell = w * " " + if n < len(cells): + cell = cells[n] + (w - len(cells[n])) * " " + if n < len(cells) - 1: + end = False + lines[-1] += cell + n += 1 + return "\n".join(lines) + + @property + def no_cols(self): + return len(self.cols) + + @property + def w(self): + return sum(c.w for c in self.cols) + + @property + def h(self): + return max(c.h for c in self.cols) + + @property + def ratio(self): + return self.h / self.w + + def scale(self, alpha): + for c in self.cols: + c.scale(alpha) + + def scale_to_fit(self, max_w, max_h=None): + if max_h is None or self.w * max_h > self.h * max_w: + self.scale(max_w / self.w) + else: + self.scale(max_h / self.h) + + def next_free_col(self): + """Returns the column with lowest height""" + minimum = min(c.h for c in self.cols) + candidates = [] + for c in self.cols: + if c.h == minimum: + candidates.append(c) + return random.choice(candidates) + + def add_cell_single_col(self, col, photo): + col.cells.append(Cell((col,), photo)) + + def add_cell_multi_col(self, col1, col2, photo): + cell = Cell((col1, col2), photo) + extent = CellExtent(cell) + col1.cells.append(cell) + col2.cells.append(extent) + + def add_cell(self, photo): + """Add a new cell in the best computed place + + If possible, and if it's worth, make a "multiple-column" cell. + + """ + col = self.next_free_col() + left = col.left_neighbor() + right = col.right_neighbor() + if 2 * random.random() > photo.ratio: + if left and abs(col.h - left.h) < 0.5 * col.w: + return self.add_cell_multi_col(left, col, photo) + elif right and abs(col.h - right.h) < 0.5 * col.w: + return self.add_cell_multi_col(col, right, photo) + + self.add_cell_single_col(col, photo) + + def remove_empty_cols(self): + i = 0 + while i < len(self.cols): + if len(self.cols[i].cells) == 0: + self.cols.pop(i) + else: + i += 1 + + def remove_bottom_holes(self): + """Remove holes created by extended cells + + Example (case A): + The bottom-right cell should be extended to fill the hole. + ---------------------- ---------------------- + | | | | | | | | + | |-------------| | |-------------| + |------| | |------| | + | |-------------- | |-------------- + | | | ^ | | ^ | | + --------------- hole -------- hole -------- + + Example (case B): + The bottom cell should be moved under the other extended cell. + ---------------------- ---------------------- + | | | | | | | | + |------|-------------| |-------------|------| + | | | | | | + |--------------------- ---------------------| + | | <-- hole hole -> | | + --------------- --------------- + + """ + for col in self.cols: + cell = col.cells[-1] + if cell == col.cells[0]: + continue + + # Case A + # If cell is not extended, is below an extended cell and has no + # neighbour under the latter, it should be extended. + if not cell.is_extended() and not cell.is_extension(): + # Case A1 + if cell.top_neighbor().is_extended() \ + and cell.top_neighbor().extent \ + .bottom_neighbor() is None: + # Extend cell to right + extent = CellExtent(cell) + col.right_neighbor().cells.append(extent) + cell.parents = (col, col.right_neighbor()) + # Case A2 + elif cell.top_neighbor().is_extension() \ + and cell.top_neighbor().origin \ + .bottom_neighbor() is None: + # Extend cell to left + col.cells.remove(cell) + col.left_neighbor().cells.append(cell) + extent = CellExtent(cell) + col.cells.append(extent) + cell.parents = (col.left_neighbor(), col) + # Case B + # If cell is extended and one of the cells above is extended too, + # the bottom cell should be placed right below the top one. + elif cell.is_extended() and cell.extent.bottom_neighbor() is None: + # Case B1 + if cell.extent.top_neighbor().is_extended() \ + and cell.extent.top_neighbor().extent \ + .bottom_neighbor() is None: + # Move cell to right + col.cells.remove(cell) + col.right_neighbor().cells.remove(cell.extent) + col.right_neighbor().cells.append(cell) + col.right_neighbor().right_neighbor().cells \ + .append(cell.extent) + cell.parents = (col.right_neighbor(), + col.right_neighbor().right_neighbor()) + # Case B2 + elif cell.top_neighbor().is_extension() \ + and cell.top_neighbor().origin \ + .bottom_neighbor() is None: + # Move cell to left + col.cells.remove(cell) + col.right_neighbor().cells.remove(cell.extent) + col.left_neighbor().cells.append(cell) + col.cells.append(cell.extent) + cell.parents = (col.left_neighbor(), col) + + def adjust_cols_heights(self): + """Set all columns' heights to same value by shrinking them""" + target_h = self.w * self.target_ratio + for c in self.cols: + c.adjust_height(target_h) + + def adjust(self): + self.remove_empty_cols() + self.remove_bottom_holes() + self.adjust_cols_heights() + + def get_cell_at_position(self, x, y): + for col in self.cols: + if x >= col.x and x < col.x + col.w: + for cell in col.cells: + if y >= cell.y and y < cell.y + cell.h: + if cell.is_extension(): + return cell.origin + return cell + return None + + def swap_photos(self, cell1, cell2): + cell1.photo, cell2.photo = cell2.photo, cell1.photo 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) -- cgit v1.2.3