diff options
Diffstat (limited to 'bin/photocollage/collage.py')
| -rw-r--r-- | bin/photocollage/collage.py | 568 |
1 files changed, 568 insertions, 0 deletions
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 |