Advertisement
creamygoat

Stitch

Apr 17th, 2014
384
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 49.13 KB | None | 0 0
  1. #!/usr/bin/python3
  2.  
  3. '''Creates a multicolumn screenshot from a series of Facebook screenshots.
  4.  
  5. NAME
  6.  stitch
  7.  
  8. SYNOPSIS
  9.  stitch [-h] [-d DIR] [-f FMT] [-g GUTTER] [-y HEIGHT] [-m MARGIN] [-r]
  10.              [-t TOLERANCE] [-q] [-v] [-V] number-of-screenshots output-file
  11.  
  12. DESCRIPTION
  13.  This script loads a series of Facebook screenshots of a single comment
  14.  thread and generates a large multicolumn screenshot image file. The filenames
  15.  are indicated by means of a C format string. The defult format string is
  16.  "%02d.png" so that the image filenames are 01.png, 02.png, 03.png and so on.
  17.  
  18. Author:
  19.  Daniel Neville (Blancmange), creamygoat@gmail.com
  20.  
  21. Copyright:
  22.  None
  23.  
  24. Licence:
  25.  Public domain
  26.  
  27.  
  28. INDEX
  29.  
  30.  
  31. Imports
  32.  
  33. Constants
  34.  
  35. Exceptions:
  36.  
  37.  Error
  38.  ArgError
  39.  FileError
  40.  LimitError
  41.  
  42. Vector functions:
  43.  
  44.  VNeg(A)
  45.  VSum(*VectorArgs)
  46.  VDiff(A, B)
  47.  VManhattan(A)
  48.  
  49. tVStrip:
  50.  
  51.  __init__(Imgs)
  52.  __getitem__(Pos)
  53.  __setitem__(Pos, Colour)
  54.  IndexY(y)
  55.  Segments(Box)
  56.  GetPixel(Pos)
  57.  SetPixel(Pos, Colour)
  58.  SubImage(SourceBox)
  59.  PasteTo(DestImg, Pos, SourceBox)
  60.  Imgs
  61.  Fences
  62.  Count
  63.  Width
  64.  Height
  65.  
  66. Pixel scanning functions:
  67.  
  68.  PixelInBounds(Pos, Bounds)
  69.  FindFirstPixel(Pixels, Bounds, MatchValue, StartPos, DeltaPos)
  70.  FindRunEnd(Pixels, Bounds, StartPos, DeltaPos)
  71.  
  72. Vertical stitching functions:
  73.  
  74.  FindStripBounds(Img)
  75.  ImageVOverlaps(Img1, Img2, Tolerance=0)
  76.  WeightedAverageOverlap(OverlapLists)
  77.  MostLikelyOverlaps(OverlapLists)
  78.  NonOverlappingImages(OverlappingImages, Overlaps)
  79.  VTrimmedVStrip(VStrip)
  80.  
  81. Comment detection and layout:
  82.  
  83.  FindCommentBreak(VStrip, StartPos, RightMargin, BottomMargin)
  84.  FindCommentBreakMetrics(VStrip)
  85.  WrappedCommentRuns(
  86.      CmtExts, CapHeight, Margin, Gutter, TargetHeight, StripWidth)
  87.  
  88. Image modification functions:
  89.  
  90.  SuppressNewCommentsMarker(VStrip)
  91.  NewCapImgs(VStrip, CmtBreakSpan, CapHeight)
  92.  
  93. Main:
  94.  
  95.  Main()
  96.  
  97.  
  98. '''
  99.  
  100.  
  101. #-------------------------------------------------------------------------------
  102. # Imports
  103. #-------------------------------------------------------------------------------
  104.  
  105.  
  106. import sys
  107. import traceback
  108. import os
  109. import argparse
  110. from math import sqrt
  111.  
  112. from PIL import Image
  113.  
  114.  
  115. #-------------------------------------------------------------------------------
  116. # Constants
  117. #-------------------------------------------------------------------------------
  118.  
  119.  
  120. # Version string
  121.  
  122. VersionStr = '1.0.1.0'
  123.  
  124. # Detection colours
  125.  
  126. PageBGColour = (233, 235, 238)
  127. HeaderBGColour = (255, 255, 255)
  128. CommentBGColour = (246, 247, 249)
  129. NewCommentMarkerColour = (64, 128, 255)
  130.  
  131. # Drawing colours
  132.  
  133. TopCapColour = (229, 230, 233)
  134. BottomCapColour = (208, 209, 213)
  135. DiscontinuityColour = (255, 0, 0)
  136.  
  137. # Pixel step vectors
  138.  
  139. vLeft = (-1, 0)
  140. vRight = (1, 0)
  141. vUp = (0, -1)
  142. vDown = (0, 1)
  143.  
  144.  
  145. #-------------------------------------------------------------------------------
  146. # Exceptions
  147. #-------------------------------------------------------------------------------
  148.  
  149.  
  150. class Error (Exception):
  151.   pass
  152.  
  153. class ArgError (Error):
  154.   pass
  155.  
  156. class FileError(Error):
  157.   pass
  158.  
  159. class LimitError(Error):
  160.   pass
  161.  
  162. class ScanningError(Error):
  163.   pass
  164.  
  165. #-------------------------------------------------------------------------------
  166. # Vector functions
  167. #-------------------------------------------------------------------------------
  168.  
  169.  
  170. def VNeg(A):
  171.   return tuple(-x for x in A)
  172.  
  173. def VSum(*VectorArgs):
  174.   if len(VectorArgs) == 1:
  175.     Vectors = VectorArgs[0]
  176.   else:
  177.     Vectors = VectorArgs
  178.   Result = tuple(Vectors[0])
  179.   for i in range(1, len(Vectors)):
  180.     Result = tuple(a + b for a, b in zip(Result, Vectors[i]))
  181.   return Result
  182.  
  183. def VDiff(A, B):
  184.   return tuple(x - y for x, y in zip(A, B))
  185.  
  186. def VManhattan(A):
  187.   return sum(abs(x) for x in A)
  188.  
  189.  
  190. #-------------------------------------------------------------------------------
  191. # tVStrip
  192. #-------------------------------------------------------------------------------
  193.  
  194.  
  195. class tVStrip (object):
  196.  
  197.   '''Keeps a list of vertically stacked images.
  198.  
  199.  The pixels in a tVStrip can be accessed with [x, y], just like the pixel
  200.  access object provided by the load() method of images.
  201.  
  202.  Methods:
  203.  
  204.    __init__(Imgs)
  205.    __getitem__(Pos)
  206.    __setitem__(Pos, Colour)
  207.    IndexY(y)
  208.    Segments(Box)
  209.    GetPixel(Pos)
  210.    SetPixel(Pos, Colour)
  211.    SubImage(SourceBox)
  212.    PasteTo(DestImg, Pos, SourceBox)
  213.  
  214.  Fields:
  215.  
  216.    Imgs
  217.    Fences
  218.    Count
  219.    Width
  220.    Height
  221.  
  222.  '''
  223.  
  224.   #-----------------------------------------------------------------------------
  225.  
  226.   def __init__(self, Imgs):
  227.  
  228.     '''Create a keeper of vertically stacked images to be alalysed as a unit.
  229.  
  230.    Imgs is a sequence of images of the same width, in top-to-bottom order.
  231.  
  232.    '''
  233.  
  234.     self.Imgs = Imgs
  235.  
  236.     self.Fences = [0]
  237.     y = 0
  238.  
  239.     for Img in self.Imgs:
  240.       h = Img.size[1]
  241.       self.Fences.append(y + h)
  242.       y += h
  243.  
  244.     self._IndexYs = [(0, 0)] * self.Height
  245.     Y = 0
  246.     SubY = 0
  247.  
  248.     for i, Fence in enumerate(self.Fences[1:]):
  249.       while Y < Fence:
  250.         self._IndexYs[Y] = (i, SubY)
  251.         Y += 1
  252.         SubY += 1
  253.       SubY = 0
  254.  
  255.   #-----------------------------------------------------------------------------
  256.  
  257.   def __getitem__(self, Pos):
  258.  
  259.     '''Return a pixel colour with the form Colour = VStrip[x, y].'''
  260.  
  261.     if (Pos is tuple or Pos is list) and len(Pos) == 2:
  262.       raise TypeError('An (x, y) tuple is required.')
  263.     if not (0 <= Pos[0] < self.Width and 0 <= Pos[1] < self.Height):
  264.       raise KeyError('Pixel coordinates ' + Pos + ' out of bounds.')
  265.     return self.GetPixel(Pos)
  266.  
  267.   #-----------------------------------------------------------------------------
  268.  
  269.   def __setitem__(self, Pos, Colour):
  270.  
  271.     '''Set a pixel colour with the form VStrip[x, y] = Colour.'''
  272.  
  273.     if (Pos is tuple or Pos is list) and len(Pos) == 2:
  274.       raise TypeError('An (x, y) tuple is required.')
  275.     if not (0 <= Pos[0] < self.Width and 0 <= Pos[1] < self.Height):
  276.       raise KeyError('Pixel coordinates ' + Pos + ' out of bounds.')
  277.     self.SetPixel(Pos, Colour)
  278.  
  279.   #-----------------------------------------------------------------------------
  280.  
  281.   @property
  282.   def Count(self):
  283.     '''Return the number of images loaded.'''
  284.     return len(self.Imgs)
  285.  
  286.   @property
  287.   def Width(self):
  288.     '''Return the width of the image strip.'''
  289.     return self.Imgs[0].size[0] if len(self.Imgs) > 0 else None
  290.  
  291.   @property
  292.   def Height(self):
  293.     '''Return the height of the image strip.'''
  294.     return self.Fences[-1]
  295.  
  296.   #-----------------------------------------------------------------------------
  297.  
  298.   def IndexY(self, y):
  299.     '''Return the (zero-based) image index and its row index given y.'''
  300.     return self._IndexYs[y]
  301.  
  302.   #-----------------------------------------------------------------------------
  303.  
  304.   def Segments(self, Box):
  305.  
  306.     '''Return image indices and image bounds spanned by Box.
  307.  
  308.    Box is in the form (Left, Top, Right, Bottom). Box must already be
  309.    correctly clipped horizontally.
  310.  
  311.    The result is a list, possibly empty, of image-bound pairs of the form
  312.    (Index, (Left, Top, Right, Bottom)).
  313.  
  314.    '''
  315.  
  316.     Result = []
  317.  
  318.     BoxWidth = Box[2] - Box[0]
  319.     BoxHeight = Box[3] - Box[1]
  320.  
  321.     if BoxWidth > 0 and BoxHeight > 0:
  322.  
  323.       Ix, SubY0 = self._IndexYs[Box[1]]
  324.       UnclippedSubY1 = SubY0 + BoxHeight
  325.  
  326.       while UnclippedSubY1 > SubY0:
  327.         ImgH = self.Fences[Ix + 1] - self.Fences[Ix]
  328.         SubY1 = min(ImgH, UnclippedSubY1)
  329.         Result.append((Ix, (Box[0], SubY0, Box[2], SubY1)))
  330.         Ix += 1
  331.         SubY0 = 0
  332.         UnclippedSubY1 -= ImgH
  333.  
  334.     return Result
  335.  
  336.   #-----------------------------------------------------------------------------
  337.  
  338.   def GetPixel(self, Pos):
  339.  
  340.     '''Get a single pixel from the strip image.
  341.  
  342.    No bounds checking is performed.
  343.  
  344.    '''
  345.  
  346.     n, y = self._IndexYs[Pos[1]]
  347.     return self.Imgs[n].getpixel((Pos[0], y))
  348.  
  349.   #-----------------------------------------------------------------------------
  350.  
  351.   def SetPixel(self, Pos, Colour):
  352.  
  353.     '''Set the colour of a single pixel from the strip image.
  354.  
  355.    No bounds checking is performed.
  356.  
  357.    '''
  358.  
  359.     n, y = self._IndexYs[Pos[1]]
  360.     self.Imgs[n].putpixel((Pos[0], y), Colour)
  361.  
  362.   #-----------------------------------------------------------------------------
  363.  
  364.   def SubImage(self, Box):
  365.  
  366.     '''Returns a image copied from a portion of the strip image.
  367.  
  368.    Though Box may straddle component images, it must already be clipped
  369.    to the boundaries of the strip image.
  370.  
  371.    '''
  372.  
  373.     w = Box[2] - Box[0]
  374.     h = Box[3] - Box[1]
  375.  
  376.     Result = Image.new(self.Imgs[0].mode, (w, h))
  377.     self.PasteTo(Result, (0, 0), Box)
  378.  
  379.     return Result
  380.  
  381.   #-----------------------------------------------------------------------------
  382.  
  383.   def PasteTo(self, DestImg, Pos, SourceBox):
  384.  
  385.     '''Pastes a portion of the strip image to a portion of a regular image.
  386.  
  387.    Though SourceBox may straddle component images, it must already be clipped
  388.    to the boundaries of the strip image.
  389.  
  390.    '''
  391.  
  392.     S = self.Segments(SourceBox)
  393.  
  394.     x, y = Pos
  395.     for (Ix, Box) in S:
  396.       DestImg.paste(self.Imgs[Ix].crop(Box), (x, y))
  397.       y += Box[3] - Box[1]
  398.  
  399.   #-----------------------------------------------------------------------------
  400.  
  401.  
  402. #-------------------------------------------------------------------------------
  403. # Pixel scanning functions
  404. #-------------------------------------------------------------------------------
  405.  
  406.  
  407. def PixelInBounds(Pos, Bounds):
  408.  
  409.   '''Test if a pixel specified by its top-left corner is within bounds.
  410.  
  411.  Pos is the (x, y) coordinates of the top-left of the pixel.
  412.  Bounds is (Left, Top, Right, Bottom) measured in pixel edges.
  413.  
  414.  '''
  415.  
  416.   x, y = Pos
  417.  
  418.   return (x >= Bounds[0] and x < Bounds[2] and
  419.       y >= Bounds[1] and y < Bounds[3])
  420.  
  421.  
  422. #-------------------------------------------------------------------------------
  423.  
  424.  
  425. def FindFirstPixel(Pixels, Bounds, MatchValue, StartPos, DeltaPos):
  426.  
  427.   '''Find the first pixel of a given value (colour) in a given direction.
  428.  
  429.  If a matching pixel is found within Bounds, the position of that pixel
  430.  (or rather its top-left corner) is returned. The pixel indicated by
  431.  StartPos is included in the search.
  432.  
  433.  Pixels is the pixel access object provided by the load() method of an image.
  434.  
  435.  Bounds must already be clipped to the boundaries of the image.
  436.  
  437.  DeltaPos is in the form (dx, dy) just as StartPos is in the form (x, y).
  438.  
  439.  If no match is found within Bounds, None is returned.
  440.  
  441.  '''
  442.  
  443.   Result = None
  444.   Pos = tuple(StartPos)
  445.  
  446.   while PixelInBounds(Pos, Bounds):
  447.     if Pixels[Pos[0], Pos[1]] == MatchValue:
  448.       Result = Pos
  449.       break
  450.     Pos = VSum(Pos, DeltaPos)
  451.  
  452.   return Result
  453.  
  454.  
  455. #-------------------------------------------------------------------------------
  456.  
  457.  
  458. def FindRunEnd(Pixels, Bounds, StartPos, DeltaPos):
  459.  
  460.   '''Find the last pixel in a run of colours within a rectangular region..
  461.  
  462.  if the pixel (whose top-left corner is) at StartPos is not within Bounds,
  463.  None is returned. If the run reaches or extends beyond Bounds, the position
  464.  of (the top-left corner of the) last pixel within the bounds is returned.
  465.  
  466.  Pixels is the pixel access object provided by the load() method of an image.
  467.  
  468.  Bounds must already be clipped to the boundaries of the image.
  469.  
  470.  Almost always, the only useful values DeltaPos for this function are (0, 1),
  471.  (0, -1), (1, 0) or (-1, 0).
  472.  
  473.  '''
  474.  
  475.   Result = None
  476.  
  477.   if PixelInBounds(StartPos, Bounds):
  478.  
  479.     MatchValue = Pixels[StartPos[0], StartPos[1]]
  480.     Pos = tuple(StartPos)
  481.  
  482.     while PixelInBounds(Pos, Bounds):
  483.       if Pixels[Pos[0], Pos[1]] != MatchValue:
  484.         Result = Pos
  485.         break
  486.       Pos = VSum(Pos, DeltaPos)
  487.  
  488.   return Result
  489.  
  490.  
  491. #-------------------------------------------------------------------------------
  492. # Vertical stitching functions
  493. #-------------------------------------------------------------------------------
  494.  
  495.  
  496. def FindStripBounds(Img):
  497.  
  498.   '''Find the area within a screenshot which contains the thread strip.'''
  499.  
  500.   #-----------------------------------------------------------------------------
  501.  
  502.   def StripEdgeCandidates(Pixels, Bounds, y, Dir):
  503.  
  504.     '''Along an image row, find likely comment strip edge locations.
  505.  
  506.    Dir may be:
  507.       1: Scan left to right and find left edges
  508.      -1: Scan right to left and find right edges
  509.  
  510.    '''
  511.  
  512.     Result = []
  513.  
  514.     if PageBGColour in ((HeaderBGColour, CommentBGColour)):
  515.       return Result
  516.  
  517.     BG = PageBGColour
  518.     StepSize = 4
  519.     StraddleSize = 8
  520.     OutStepSize = 1
  521.     InStepSize = 3
  522.     VSpan = 16
  523.  
  524.     if Dir > 0:
  525.  
  526.       Step = StepSize
  527.       Straddle = StraddleSize
  528.       OutStep = -OutStepSize
  529.       InStep = InStepSize
  530.       x = Bounds[0]
  531.       DeltaPos = vRight
  532.       HVerifyRng = (InStep, Straddle)
  533.  
  534.     else:
  535.  
  536.       Step = -StepSize
  537.       Straddle = -StraddleSize
  538.       OutStep = OutStepSize
  539.       InStep = -InStepSize
  540.       x = Bounds[2] - 1
  541.       DeltaPos = vLeft
  542.       HVerifyRng = (-Straddle + 1, InStep + 1)
  543.  
  544.     n = (Bounds[2] - Bounds[0] - StraddleSize - 100) // StepSize
  545.     v0 = max(Bounds[1], y - (VSpan // 2))
  546.     v1 = min(Bounds[3], y - (VSpan // 2) + VSpan)
  547.     VRng = (v0, v1)
  548.  
  549.     while n > 0:
  550.       if Pixels[x, y] == BG:
  551.         Col = Pixels[x + Straddle, y]
  552.         if Col in (HeaderBGColour, CommentBGColour):
  553.           x1 = FindRunEnd(Pixels, Bounds, (x, y), DeltaPos)[0]
  554.           if all(Pixels[x1 + i, y] == Col for i in range(*HVerifyRng)):
  555.             if all(Pixels[x1 + OutStep, i] == BG for i in range(*VRng)):
  556.               if all(Pixels[x1 + InStep, i] == Col for i in range(*VRng)):
  557.                 Candidate = x1 + 1 if Step < 0 else x1
  558.                 Result.append(Candidate)
  559.       x += Step
  560.       n -= 1
  561.  
  562.     return Result
  563.  
  564.   #-----------------------------------------------------------------------------
  565.  
  566.   def FindStripEdge(Pixels, Bounds, Dir):
  567.  
  568.     '''Find a likely comment strip edge x value.
  569.  
  570.    Dir may be:
  571.       1: Scan left to right and find left edges
  572.      -1: Scan right to left and find right edges
  573.  
  574.    Several image rows are scanned and a histogram is built. An attempt is
  575.    made to select the comment strip edges but not the right side panel edges.
  576.  
  577.    '''
  578.  
  579.     Result = None
  580.  
  581.     if PageBGColour in ((HeaderBGColour, CommentBGColour)):
  582.       return Result
  583.  
  584.     Right = Bounds[2]
  585.     H = {}
  586.  
  587.     for y in range(Bounds[1] + 20, Bounds[3] - 20, 15):
  588.  
  589.       C = StripEdgeCandidates(Pixels, Bounds, y, Dir)
  590.  
  591.       for x in C:
  592.         Weight = Right - x
  593.         if x in H:
  594.           H[x] += Weight
  595.         else:
  596.           H[x] = Weight
  597.  
  598.     if len(H) > 0:
  599.       MaxWeight = max(H[x] for x in H)
  600.       Result = min(x for x in H if H[x] == MaxWeight)
  601.  
  602.     return Result
  603.  
  604.   #-----------------------------------------------------------------------------
  605.  
  606.   Bounds = [0, 0] + list(Img.size)
  607.   Pixels = Img.load()
  608.  
  609.   Top = None
  610.   Bottom = None
  611.   LBottom = None
  612.   RBottom = None
  613.   Left = FindStripEdge(Pixels, Bounds, 1)
  614.   Right = FindStripEdge(Pixels, Bounds, -1)
  615.  
  616.   # Avoid the Facebook masthead, the bottom window border, the chat tab
  617.   # at the lower right and possibly Firefox's URL overlay..
  618.  
  619.   if Left is not None:
  620.     y = Img.size[1] // 2
  621.     TopPos = FindRunEnd(Pixels, Bounds, (Left - 1, y), vUp)
  622.     BotPos = FindRunEnd(Pixels, Bounds, (Left - 1, y), vDown)
  623.     Top = TopPos[1] + 1 if TopPos is not None else None
  624.     LBottom = BotPos[1] if BotPos is not None else None
  625.  
  626.   if Right is not None:
  627.     y = Img.size[1] // 2
  628.     BotPos = FindRunEnd(Pixels, Bounds, (Right + 2, y), vDown)
  629.     RBottom = BotPos[1] if BotPos is not None else None
  630.  
  631.   if LBottom is not None:
  632.     if RBottom is not None:
  633.       Bottom = min(LBottom, RBottom)
  634.     else:
  635.       Bottom = LBottom
  636.   else:
  637.     Bottom = RBottom
  638.  
  639.   if Left is not None:
  640.     Bounds[0] = Left
  641.   if Right is not None:
  642.     Bounds[2] = Right
  643.   if Top is not None:
  644.     Bounds[1] = Top
  645.   if Bottom is not None:
  646.     Bounds[3] = Bottom
  647.  
  648.   return tuple(Bounds)
  649.  
  650.  
  651. #-------------------------------------------------------------------------------
  652.  
  653.  
  654. def ImageVOverlaps(Img1, Img2, Tolerance=0):
  655.  
  656.   '''Measure how much overlap there is between two successive images.
  657.  
  658.  Because repetition in the images can mean multiple overlaps are valid,
  659.  a list of overlap candidates is returned.
  660.  
  661.  If no overlap is found, an empty list is returned.
  662.  
  663.  Because scrolling in Firefox often changes the colours of some pixels
  664.  in an image by one or two a few levels per channel, a tolerance of 3
  665.  is recommended.
  666.  
  667.  '''
  668.  
  669.   Width = min(Img1.size[0], Img2.size[0])
  670.  
  671.   #-----------------------------------------------------------------------------
  672.  
  673.   def RowsAreMatching(Pixels1, y1, Pixels2, y2, x0, x1):
  674.  
  675.     '''Return True iff two image rows match within Tolerance.
  676.  
  677.    The horizontal bounds of the scan is defined by x0 <= x < x1
  678.    where x is the coordinates of the upper-left of a pixel to test.
  679.  
  680.    '''
  681.  
  682.     Result = True
  683.  
  684.     for x in range(x0, x1):
  685.  
  686.       if Pixels1[x, y1] != Pixels2[x, y2]:
  687.  
  688.         Pix1 = Pixels1[x, y1]
  689.         Pix2 = Pixels2[x, y2]
  690.         Diff = VDiff(Pix1, Pix2)
  691.  
  692.         if max(abs(v) for v in Diff) > Tolerance:
  693.           Result = False
  694.           break
  695.  
  696.     return Result
  697.  
  698.   #-----------------------------------------------------------------------------
  699.  
  700.   Result = []
  701.  
  702.   Pixels1 = Img1.load()
  703.   Height1 = Img1.size[1]
  704.   Pixels2 = Img2.load()
  705.   Height2 = Img2.size[1]
  706.  
  707.   MidX = Width // 2
  708.  
  709.   # Prepare the initial list of possible overlaps.
  710.  
  711.   for y1 in range(Height1):
  712.     if RowsAreMatching(Pixels1, y1, Pixels2, 0, MidX, MidX + 1):
  713.       Result.append(Height1 - y1)
  714.  
  715.   # Perform a quick screening test followed by a more thorough test
  716.   # of the remaining candidates.
  717.  
  718.   for (StartY, x0, x1) in [(0, MidX, MidX + 1), (0, 0, Width)]:
  719.     NewResult = []
  720.     for Overlap in Result:
  721.       y2 = StartY
  722.       y1 = Height1 - Overlap + StartY
  723.       IsMatching = True
  724.       while y1 < Height1 and y2 < Height2:
  725.         if not RowsAreMatching(Pixels1, y1, Pixels2, y2, x0, x1):
  726.           IsMatching = False
  727.           break
  728.         y1 += 1
  729.         y2 += 1
  730.       if IsMatching:
  731.         NewResult.append(Overlap)
  732.     Result = NewResult
  733.  
  734.   return Result
  735.  
  736.  
  737. #-------------------------------------------------------------------------------
  738.  
  739.  
  740. def WeightedAverageOverlap(OverlapLists):
  741.  
  742.   '''Weighing greedy overlaps more, find the average weighted overlap.
  743.  
  744.  OverlapLists is a list of overlap lists. Most image pairs will have an
  745.  overlap list of just one entry and most of the time, image pairs will
  746.  have identical single-element lists. The weighted average overlap will
  747.  be useful for selecting the best overlap candidate for tricky image pairs.
  748.  
  749.  '''
  750.  
  751.   Result = 0
  752.  
  753.   Acc = 0.0
  754.   SumW = 0.0
  755.  
  756.   for Overlaps in OverlapLists:
  757.  
  758.     n = len(Overlaps)
  759.  
  760.     if n > 0:
  761.       if n == 1:
  762.         Acc += Overlaps[0]
  763.         SumW += 1.0
  764.       else:
  765.         InnerAcc = 0.0
  766.         InnerSumW = 0.0
  767.         for i, v in enumerate(reversed(sorted(Overlaps))):
  768.           w = 1.0 / float(1 + i)
  769.           InnerAcc += float(v) * w
  770.           InnerSumW += w
  771.         Acc += InnerAcc / InnerSumW
  772.         SumW += 1.0
  773.  
  774.   Result = Acc / SumW if SumW > 0 else 0.0
  775.  
  776.   return Result
  777.  
  778.  
  779. #-------------------------------------------------------------------------------
  780.  
  781.  
  782. def MostLikelyOverlaps(OverlapLists):
  783.  
  784.   '''Determine a single overlap value for each pair of successive images.
  785.  
  786.  Most of the time, each image pair has an overlap list of one element and
  787.  that element is shared by the other overlap lists. For each image pair's
  788.  overlap lists, the element closest to the weighted average overlap is
  789.  selected.
  790.  
  791.  '''
  792.  
  793.   Result = []
  794.  
  795.   z = WeightedAverageOverlap(OverlapLists)
  796.  
  797.   for L in OverlapLists:
  798.     Overlap = 0
  799.     if len(L) > 0:
  800.       Deviations = [abs(v - z) for v in L]
  801.       Overlap = L[Deviations.index(min(Deviations))]
  802.     Result.append(Overlap)
  803.  
  804.   return Result
  805.  
  806.  
  807. #-------------------------------------------------------------------------------
  808.  
  809.  
  810. def NonOverlappingImages(OverlappingImages, Overlaps):
  811.  
  812.   '''Create a list of non-overlapping images.
  813.  
  814.  In the case of 100% overlaps, images may be excluded from the list
  815.  returned. Therefore the resulting image indices may be different.
  816.  
  817.  '''
  818.  
  819.   BMH = 6 # Break mark height
  820.  
  821.   Result = []
  822.   Imgs = OverlappingImages
  823.  
  824.   if len(Imgs) > 0:
  825.  
  826.     Width = Imgs[0].size[0]
  827.  
  828.     # Trim overlaps and mark discontinuities
  829.  
  830.     NewImgs = []
  831.     PrevHadBreak = False
  832.  
  833.     for ImgIx, Img in enumerate(Imgs):
  834.  
  835.       if ImgIx + 1 < len(Imgs):
  836.         Overlap = Overlaps[ImgIx]
  837.         CurrentHasBreak = Overlap < 1
  838.       else:
  839.         Overlap = 0
  840.         CurrentHasBreak = False
  841.  
  842.       NewImg = None
  843.       h = Img.size[1] - max(0, Overlap)
  844.  
  845.       if not (PrevHadBreak or CurrentHasBreak):
  846.  
  847.         if h > 0:
  848.           NewImg = Img.crop((0, 0, Width, h))
  849.  
  850.       else:
  851.  
  852.         TopExtra = BMH // 2 if PrevHadBreak else 0
  853.         BotExtra = BMH - (BMH // 2) if CurrentHasBreak else 0
  854.         y0 = TopExtra
  855.         y1 = TopExtra + h
  856.         NewH = y1 + BotExtra
  857.         NewImg = Image.new('RGB', (Width, NewH), PageBGColour)
  858.  
  859.         if h > 0:
  860.           CroppedImg = Img.crop((0, 0, Width, h))
  861.           NewImg.paste(CroppedImg, (0, y0))
  862.  
  863.         if PrevHadBreak:
  864.           NewImg.paste(DiscontinuityColour, (0, 0, Width, y0))
  865.  
  866.         if CurrentHasBreak:
  867.           NewImg.paste(DiscontinuityColour, (0, y1, Width, NewH))
  868.  
  869.       if NewImg is not None:
  870.         NewImgs.append(NewImg)
  871.  
  872.       PrevHadBreak = CurrentHasBreak
  873.  
  874.     Result = NewImgs
  875.  
  876.     # We now have an array of perfectly stacked, non-overlapping images.
  877.     # The images numbers may have changed due to deletions resulting
  878.     # from 100% overlaps.
  879.  
  880.   return Result
  881.  
  882.  
  883. #-------------------------------------------------------------------------------
  884.  
  885.  
  886. def VTrimmedVStrip(VStrip):
  887.  
  888.   '''Create a VStrip image with the top and bottom non-thread bits trimmed.
  889.  
  890.  Facebook threads are separated from adjacent threads by regions of solid
  891.  page background colour. This function looks for a breaks in the first
  892.  image anf from there, the next break in order to determine which parts
  893.  of the strip to return as a new tVStrip.
  894.  
  895.  '''
  896.  
  897.   #-----------------------------------------------------------------------------
  898.  
  899.   def IsBlankOrOOB(Pixels, Bounds, y):
  900.  
  901.     '''Return True is a scan line is blank or out of bounds.'''
  902.  
  903.     if y < Bounds[1] or y >= Bounds[3]:
  904.       return True
  905.     else:
  906.       BG = PageBGColour
  907.       return all(Pixels[x, y] == BG for x in range(Bounds[0], Bounds[2]))
  908.  
  909.   #-----------------------------------------------------------------------------
  910.  
  911.   NewImgs = []
  912.  
  913.   if VStrip.Height > 0:
  914.  
  915.     Img = VStrip.Imgs[0]
  916.     Bounds = (0, 0) + (Img.size)
  917.     Pixels = Img.load()
  918.  
  919.     Top = 0
  920.     BotSearchStart = 0
  921.     Pos = FindFirstPixel(Pixels, Bounds, PageBGColour, (0, 0), vDown)
  922.     if Pos is not None:
  923.       Pos = FindRunEnd(Pixels, Bounds, Pos, vDown)
  924.       if Pos is not None:
  925.         Top = Pos[1] + 1
  926.         BotSearchStart = Top
  927.         while not IsBlankOrOOB(Pixels, Bounds, Top - 1):
  928.           Top -= 1
  929.  
  930.     if VStrip.Height > Top:
  931.  
  932.       Bounds = (0, 0, VStrip.Width, VStrip.Height)
  933.       Bottom = VStrip.Height
  934.  
  935.       Pos = FindFirstPixel(VStrip, Bounds, PageBGColour,
  936.           (0, BotSearchStart), vDown)
  937.  
  938.       if Pos is not None:
  939.         Bottom = Pos[1]
  940.         while not IsBlankOrOOB(VStrip, Bounds, Bottom):
  941.           Bottom += 1
  942.  
  943.       for ImgIx in range(VStrip.Count):
  944.         y0, y1 = VStrip.Fences[ImgIx : ImgIx + 2]
  945.         c0, c1 = max(y0, Top), min(y1, Bottom)
  946.         if c1 > c0:
  947.           Img = VStrip.Imgs[ImgIx]
  948.           if c1 == y0 and c1 == y1:
  949.             NewImgs.append(Img)
  950.           else:
  951.             NewImgs.append(Img.crop((0, c0 - y0, VStrip.Width, c1 - y0)))
  952.  
  953.   Result = tVStrip(NewImgs)
  954.  
  955.   return Result
  956.  
  957.  
  958. #-------------------------------------------------------------------------------
  959. # Comment detection and layout
  960. #-------------------------------------------------------------------------------
  961.  
  962.  
  963. def FindCommentBreak(VStrip, StartPos, RightMargin, BottomMargin):
  964.  
  965.   '''Find the vertical span of an inter-comment space.
  966.  
  967.  Comments are assumed to begin with a squarish avatar image. The break is
  968.  assumed to exist between the top of the avartar and the lowest non-blank
  969.  scanline (within the thread strip box). In the case of Zalgo text, the
  970.  break may be zero rows high.
  971.  
  972.  The x-value of StartPos should indicate the leftmost edge of each avatar.
  973.  
  974.  RightMargin and BottomMargin are measured from the origin.
  975.  
  976.  If a comment break is found, (Top, Bottom) is returned. If Top and Bottom
  977.  are the same,the break has zero height. If no comment break is found, None
  978.  is returned.
  979.  
  980.  '''
  981.  
  982.   BodyCols = [HeaderBGColour, CommentBGColour]
  983.  
  984.   #-----------------------------------------------------------------------------
  985.  
  986.   def IsDiscontinuity(y):
  987.     return VStrip[max(0, StartPos[0] - 1), y] == DiscontinuityColour
  988.  
  989.   #-----------------------------------------------------------------------------
  990.  
  991.   def IsInAvatar(y):
  992.     TestColumns = [StartPos[0], StartPos[0] + 1, StartPos[0] + 7]
  993.     return any(VStrip[x, y] not in BodyCols for x in TestColumns)
  994.  
  995.   #-----------------------------------------------------------------------------
  996.  
  997.   def IsBlankLine(y, BGColour):
  998.     return all(VStrip[x, y] == BGColour
  999.         for x in range(StartPos[0], RightMargin))
  1000.  
  1001.   #-----------------------------------------------------------------------------
  1002.  
  1003.   Result = None
  1004.   BG = CommentBGColour
  1005.   x, y = StartPos
  1006.  
  1007.   while y < BottomMargin:
  1008.     if not IsDiscontinuity(y) and not IsInAvatar(y):
  1009.       BG = VStrip[x, y]
  1010.       break
  1011.     y += 1
  1012.  
  1013.   AvatarBottom = y
  1014.  
  1015.   if AvatarBottom < BottomMargin:
  1016.  
  1017.     while y < BottomMargin:
  1018.       if not IsDiscontinuity(y) and IsInAvatar(y):
  1019.         break
  1020.       y += 1
  1021.  
  1022.     NextAvatarTop = y
  1023.  
  1024.     if NextAvatarTop < BottomMargin:
  1025.  
  1026.       while y > AvatarBottom:
  1027.         if not IsDiscontinuity(y - 1) and not IsBlankLine(y - 1, BG):
  1028.           break
  1029.         y -= 1
  1030.  
  1031.       BreakTop = y
  1032.  
  1033.       Result = (BreakTop, NextAvatarTop)
  1034.  
  1035.   return Result
  1036.  
  1037.  
  1038. #-------------------------------------------------------------------------------
  1039.  
  1040.  
  1041. def FindCommentBreakMetrics(VStrip):
  1042.  
  1043.   '''Find the metrics of the thread to permit scanning for comment breaks.
  1044.  
  1045.  This function returns (Left, Right, Bottom, FirstBreak). If a comment
  1046.  break is found, FirstBreak will be the break's extent in (Top, Bottom)
  1047.  form, else None.
  1048.  
  1049.  '''
  1050.  
  1051.   BodyCols = [HeaderBGColour, CommentBGColour]
  1052.  
  1053.   #-----------------------------------------------------------------------------
  1054.  
  1055.   def IsAvatarAt(Pos, Bounds):
  1056.  
  1057.     '''Return True iff Pos marks the top of an avatar image.'''
  1058.  
  1059.     MaxWander = 120
  1060.  
  1061.     #---------------------------------------------------------------------------
  1062.  
  1063.     def IsMark(Pos):
  1064.       return VStrip[Pos] not in BodyCols
  1065.  
  1066.     #---------------------------------------------------------------------------
  1067.  
  1068.     Left = Right = Pos[0]
  1069.     Top = Bottom = Pos[1]
  1070.  
  1071.  
  1072.     LBounds = (
  1073.       max(Bounds[0], Pos[0] - MaxWander * 1 // 4),
  1074.       max(Bounds[1], Top - 1),
  1075.       min(Bounds[2], Pos[0] + MaxWander * 3 // 4),
  1076.       min(Bounds[3], Top + MaxWander)
  1077.     )
  1078.  
  1079.     Result = IsMark((Pos[0], Top))
  1080.     Width = 1
  1081.     Height = 1
  1082.  
  1083.     if Result:
  1084.  
  1085.       # Check the top edge.
  1086.  
  1087.       Result = False
  1088.  
  1089.       while Left >= LBounds[0] and IsMark((Left, Top)):
  1090.         Left -= 1
  1091.       if Left >= LBounds[0]:
  1092.         while Right < LBounds[2] and IsMark((Right, Top)):
  1093.           Right += 1
  1094.         if Right < LBounds[2]:
  1095.           Width = Right - Left
  1096.           if Width > 22:
  1097.             if not any(IsMark((x, Top - 1)) for x in range(Left, Right + 1)):
  1098.               Result = True
  1099.               Left += 1
  1100.  
  1101.       if Result:
  1102.  
  1103.         # Check the left and right edges.
  1104.  
  1105.         Result = False
  1106.         Bottom = Top + 1
  1107.  
  1108.         while Bottom < LBounds[3] and IsMark((Left, Bottom)):
  1109.           Bottom += 1
  1110.         Height = Bottom - Top
  1111.         if Bottom < LBounds[3] and 0.9 < float(Width) / Height < 1.1:
  1112.           if not any(IsMark((Left - 1, y)) for y in range(Top, Bottom)):
  1113.             if all(IsMark((Right - 1, y)) for y in range(Top, Bottom)):
  1114.               if not any(IsMark((Right, y)) for y in range(Top, Bottom)):
  1115.                 Result = True
  1116.  
  1117.       if Result:
  1118.  
  1119.         # Check the bottom edge.
  1120.  
  1121.         Result = False
  1122.  
  1123.         if all(IsMark((x, Bottom - 1)) for x in range(Left, Right)):
  1124.           if not any(IsMark((x, Bottom)) for x in range(Left - 1, Right + 1)):
  1125.             Result = True
  1126.  
  1127.     return Result
  1128.  
  1129.   #-----------------------------------------------------------------------------
  1130.  
  1131.   Result = None
  1132.  
  1133.   XSearchLimit = min(100, VStrip.Width - 100)
  1134.   YSearchLimit = VStrip.Height - 20
  1135.  
  1136.   # Find the left margin, clear of the strip box edge colours.
  1137.  
  1138.   x = 0
  1139.   y = 10
  1140.  
  1141.   while VStrip[x, y] not in BodyCols and x + 50 < VStrip.Width:
  1142.     x += 1
  1143.   x += 2
  1144.  
  1145.   LeftMargin = x
  1146.  
  1147.   # Skip past the thread header.
  1148.  
  1149.   Col = VStrip[x, y]
  1150.   if Col == HeaderBGColour:
  1151.     while y + 20 < VStrip.Height and VStrip[x, y] == Col:
  1152.       y += 10
  1153.  
  1154.   # Search for an avatar image by dropping fishing lines, each line several
  1155.   # pixels ahead of the next one to the right.
  1156.  
  1157.   FeelerVSpacing = 1000
  1158.   FeelerHSpacing = 16
  1159.   SearchBounds = (x, y, XSearchLimit, YSearchLimit)
  1160.  
  1161.   Feelers = []
  1162.   x += 8
  1163.   while x < XSearchLimit:
  1164.     Feelers.append((x, y))
  1165.     x += FeelerHSpacing
  1166.  
  1167.   AvatarPos = None
  1168.   NumTests = 0
  1169.  
  1170.   while AvatarPos is None and len(Feelers) > 0:
  1171.  
  1172.     LastFeelerY = None
  1173.     FIx = 0
  1174.  
  1175.     while FIx < len(Feelers):
  1176.  
  1177.       (x, y) = Feelers[FIx]
  1178.       if LastFeelerY is not None and y > LastFeelerY - FeelerVSpacing:
  1179.         break
  1180.  
  1181.       NumTests += 1
  1182.       FoundAvatar = IsAvatarAt((x, y), SearchBounds)
  1183.  
  1184.       if FoundAvatar:
  1185.         while VStrip[x - 1, y] not in BodyCols:
  1186.           x -= 1
  1187.         AvatarPos = (x, y)
  1188.         break
  1189.  
  1190.       y += 1
  1191.       Feelers[FIx] = (x, y)
  1192.       LastFeelerY = y
  1193.  
  1194.       if y < YSearchLimit:
  1195.         FIx += 1
  1196.       else:
  1197.         del Feelers[0]
  1198.         LastFeelerY = None
  1199.  
  1200.   if AvatarPos is not None:
  1201.  
  1202.     RightMargin = VStrip.Width - LeftMargin
  1203.     BottomMargin = max(AvatarPos[1], VStrip.Height - 80)
  1204.     FirstBreak = FindCommentBreak(VStrip, AvatarPos, RightMargin, BottomMargin)
  1205.  
  1206.     Result = (AvatarPos[0], RightMargin, BottomMargin, FirstBreak)
  1207.  
  1208.   return Result
  1209.  
  1210.  
  1211. #-------------------------------------------------------------------------------
  1212.  
  1213.  
  1214. def WrappedCommentRuns(
  1215.   CmtExts, CapHeight, Margin, Gutter, TargetHeight, StripWidth
  1216. ):
  1217.  
  1218.   '''Neatly wrap comment runs, top justified.
  1219.  
  1220.  CmtExts is a list of ((SpaceTop, SpaceBottom), (CommentTop, CommentBottom)).
  1221.  
  1222.  To allow room for caps to be attached to the ends of comment breaks
  1223.  which fall between columns, CapHeight must be specified.
  1224.  
  1225.  This function returns (MCSize, Pastes) where MCSize is (Width, Height) and
  1226.  Pastes is a list of (DestPos, SourceBox).
  1227.  
  1228.  '''
  1229.  
  1230.   Result = None
  1231.   ContentHeight = TargetHeight - 2 * Margin
  1232.  
  1233.   # Perform basic wrapping.
  1234.  
  1235.   CSMs = [(0, CmtExts[0][1] - CmtExts[0][0])]
  1236.  
  1237.   for i in range(1, len(CmtExts)):
  1238.     CSMs.append(
  1239.       (CmtExts[i][0] - CmtExts[i - 1][1], CmtExts[i][1] - CmtExts[i][0])
  1240.     )
  1241.  
  1242.   J = []
  1243.   y = 0
  1244.   Column = []
  1245.  
  1246.   for i, CSM in enumerate(CSMs):
  1247.     TopCapH = 0 if i == 0 else CapHeight
  1248.     BotCapH = 0 if i + 1 == len(CSMs) else CapHeight
  1249.     Space, Mark = CSM
  1250.     if y == 0:
  1251.       if y + TopCapH + Mark + BotCapH > ContentHeight:
  1252.         y = TopCapH + Mark
  1253.         Column.append((i, (TopCapH, y)))
  1254.         J.append(Column)
  1255.         Column = []
  1256.         y = 0
  1257.       else:
  1258.         y = TopCapH + Mark
  1259.         Column.append((i, (TopCapH, y)))
  1260.     else:
  1261.       if y + Space + Mark + BotCapH > ContentHeight:
  1262.         J.append(Column)
  1263.         TopCapH = CapHeight
  1264.         y = TopCapH + Mark
  1265.         Column = [(i, (TopCapH, y))]
  1266.       else:
  1267.         Column.append((i, (y + Space, y + Space + Mark)))
  1268.         y += Space + Mark
  1269.   if len(Column) > 0:
  1270.     J.append(Column)
  1271.     Column = []
  1272.  
  1273.   # Attempt to smooth the bottom edge.
  1274.  
  1275.   Jiggled = True
  1276.  
  1277.   while Jiggled:
  1278.     Jiggled = False
  1279.     for i in range(len(J) - 2):
  1280.       Col2IsLast = i + 1 == len(J)
  1281.       Col1 = J[i]
  1282.       Col2 = J[i + 1]
  1283.       if len(Col1) > 1:
  1284.         Bot1 = Col1[-1][1][1] + CapHeight
  1285.         Bot2 = Col2[-1][1][1] + (0 if Col2IsLast else CapHeight)
  1286.         if Bot1 > Bot2:
  1287.           Last1Ix = Col1[-1][0]
  1288.           First2Ix = Col2[0][0]
  1289.           Last1SM = sum(CSMs[Last1Ix])
  1290.           DeltaBot2 = CSMs[Last1Ix][1] + CSMs[First2Ix][0]
  1291.           NewBot1 = Bot1 - Last1SM
  1292.           NewBot2 = DeltaBot2 + Bot2
  1293.           if NewBot2 <= ContentHeight and abs(NewBot1 - NewBot2) < Bot1 - Bot2:
  1294.             J[i].pop()
  1295.             NewC2 = [(Last1Ix, (CapHeight, CapHeight + CSMs[Last1Ix][1]))]
  1296.             for (CEIx, (CTop, CBot)) in J[i + 1]:
  1297.               NewC2.append((CEIx, (CTop + DeltaBot2, CBot + DeltaBot2)))
  1298.             J[i + 1] = NewC2
  1299.             Jiggled = True
  1300.  
  1301.   # Find the final height of the multicolumn image.
  1302.  
  1303.   JHeight = 0
  1304.   BotCapH = 0
  1305.   for Col in reversed(J):
  1306.     JHeight = max(JHeight, Col[-1][1][1] + BotCapH)
  1307.     BotCapH = CapHeight
  1308.   JHeight += 2 * Margin
  1309.  
  1310.   # Position comment image blocks.
  1311.  
  1312.   Pastes = []
  1313.   x = Margin
  1314.   for i, JCol in enumerate(J):
  1315.     CmtIx0, VRange0 = JCol[0]
  1316.     CmtIx1, VRange1 = JCol[-1]
  1317.     VSRange = (CmtExts[CmtIx0][0], CmtExts[CmtIx1][1])
  1318.     Pos = (x, Margin + VRange0[0])
  1319.     VSBox = (0, VSRange[0], StripWidth, VSRange[1])
  1320.     Pastes.append((Pos, VSBox))
  1321.     x += StripWidth + Gutter
  1322.   JWidth = x - Gutter + Margin
  1323.  
  1324.   Result = ((JWidth, JHeight), Pastes)
  1325.  
  1326.   return Result
  1327.  
  1328.  
  1329. #-------------------------------------------------------------------------------
  1330. # Image modification functions
  1331. #-------------------------------------------------------------------------------
  1332.  
  1333.  
  1334. def SuppressNewCommentsMarker(VStrip):
  1335.  
  1336.   '''Erase the highlighting along the left edge which indicate new comments.'''
  1337.  
  1338.   #-----------------------------------------------------------------------------
  1339.  
  1340.   def GetReplacementColours(y, dy):
  1341.  
  1342.     '''Search vertically for a patch of pixels to use to replace the NCM.'''
  1343.  
  1344.     Result = None
  1345.  
  1346.     if y + dy >= 0 and y + dy < VStrip.Height:
  1347.  
  1348.       x = 0
  1349.       Col = VStrip[x, y]
  1350.  
  1351.       if Col == NewCommentMarkerColour:
  1352.  
  1353.         Result = []
  1354.  
  1355.         while x < 12 and Col == NewCommentMarkerColour:
  1356.           Result.append(VStrip[x, y + dy])
  1357.           x += 1
  1358.           Col = VStrip[x, y]
  1359.  
  1360.     return Result
  1361.  
  1362.   #-----------------------------------------------------------------------------
  1363.  
  1364.   def EraseNCM(y, Patch):
  1365.  
  1366.     '''Erase the new comment marker pixels at the given row.'''
  1367.  
  1368.     for i, Col in enumerate(Patch):
  1369.       VStrip[i, y] = Col
  1370.  
  1371.   #-----------------------------------------------------------------------------
  1372.  
  1373.   Patch = None
  1374.   NCMBottom = 0
  1375.  
  1376.   for y in reversed(range(VStrip.Height)):
  1377.     if VStrip[0, y] == NewCommentMarkerColour:
  1378.       if Patch is None:
  1379.         Patch = GetReplacementColours(y, 1)
  1380.       if Patch is not None:
  1381.         EraseNCM(y, Patch)
  1382.       else:
  1383.         NCMBottom = y + 1
  1384.         break
  1385.  
  1386.   if NCMBottom > 0:
  1387.  
  1388.     NCMTop = NCMBottom
  1389.  
  1390.     for y in reversed(range(NCMBottom)):
  1391.       if VStrip[0, y] != NewCommentMarkerColour:
  1392.         if Patch is None:
  1393.           Patch = GetReplacementColours(y + 1, -1)
  1394.         NCMTop = y + 1
  1395.         break
  1396.  
  1397.     if Patch is not None:
  1398.       for y in range(NCMTop, NCMBottom):
  1399.         EraseNCM(y, Patch)
  1400.  
  1401.  
  1402. #-------------------------------------------------------------------------------
  1403.  
  1404.  
  1405. def NewCapImgs(VStrip, CmtBreakSpan, CapHeight):
  1406.  
  1407.   '''Create top and bottom cap images to tidy column breaks.'''
  1408.  
  1409.   #-----------------------------------------------------------------------------
  1410.  
  1411.   def LerpCols(ColA, ColB, Fade):
  1412.  
  1413.     '''Linearly interpolate between two 8-bit-per-channel RGB' colours.
  1414.  
  1415.    Fade is the interpolation parameter 0.0..1.0 for ColA..ColB.
  1416.  
  1417.    '''
  1418.  
  1419.     Result = []
  1420.  
  1421.     Gamma = 1.0/2.2
  1422.     InvGamma = 1.0/Gamma
  1423.     Inv255 = 1.0/255.0
  1424.  
  1425.     for (LevA, LevB) in zip(ColA, ColB):
  1426.       a = (float(LevA) * Inv255) ** InvGamma
  1427.       b = (float(LevB) * Inv255) ** InvGamma
  1428.       r = a + Fade * (b - a)
  1429.       Result.append(int(round(max(0.0, min(1.0, r)) ** Gamma * 255.0)))
  1430.  
  1431.     return tuple(Result)
  1432.  
  1433.   #-----------------------------------------------------------------------------
  1434.  
  1435.   TopCap = Image.new("RGB", (VStrip.Width, CapHeight), CommentBGColour)
  1436.  
  1437.   if CmtBreakSpan is not None:
  1438.     Box = (0, CmtBreakSpan[0], VStrip.Width, CmtBreakSpan[1])
  1439.     y = 0
  1440.     while y < CapHeight:
  1441.       h = min(CapHeight - y, Box[3] - Box[1])
  1442.       Box = Box[:3] + (Box[1] + h,)
  1443.       VStrip.PasteTo(TopCap, (0, y), Box)
  1444.       y += h
  1445.  
  1446.   # The Image copy() method is buggy. It provides a copy with a
  1447.   # defective pixel access method.
  1448.  
  1449.   # Instead, paste the whole image into a new image.
  1450.  
  1451.   BotCap = Image.new("RGB", (VStrip.Width, CapHeight))
  1452.   BotCap.paste(TopCap, (0, 0))
  1453.  
  1454.   TopPixels = TopCap.load()
  1455.   BotPixels = BotCap.load()
  1456.  
  1457.   LeftCol = TopPixels[0, 0]
  1458.   RightCol = TopPixels[VStrip.Width - 1, 0]
  1459.   TopCol = TopCapColour
  1460.   BotCol = BottomCapColour
  1461.  
  1462.   for x in range(1, VStrip.Width - 1):
  1463.     TopPixels[x, 0] = TopCol
  1464.     BotPixels[x, CapHeight - 1] = BotCol
  1465.  
  1466.   TopPixels[0, 0] = LerpCols(LeftCol, TopCol, 0.5)
  1467.   TopPixels[VStrip.Width - 1, 0] = LerpCols(RightCol, TopCol, 0.5)
  1468.  
  1469.   BotPixels[0, CapHeight - 1] = LerpCols(LeftCol, BotCol, 0.5)
  1470.   BotPixels[VStrip.Width - 1, CapHeight - 1] = LerpCols(RightCol, BotCol, 0.5)
  1471.  
  1472.   return TopCap, BotCap
  1473.  
  1474.  
  1475. #-------------------------------------------------------------------------------
  1476. # Main
  1477. #-------------------------------------------------------------------------------
  1478.  
  1479.  
  1480. def Main():
  1481.  
  1482.   #-----------------------------------------------------------------------------
  1483.  
  1484.   def CheckArgRange(Name, Value, MinValue, MaxValue):
  1485.     if not MinValue <= Value <= MaxValue:
  1486.       raise ArgError('argument ' + Name + ': out of range ' + #<<<<<<<<<
  1487.           str(MinValue) + '..' + str(MaxValue) +'.')
  1488.  
  1489.   #-----------------------------------------------------------------------------
  1490.  
  1491.   def PrettyBoundsStr(B):
  1492.     return 'x: %d..%d  y: %d..%d' % (B[0], B[2], B[1], B[3])
  1493.  
  1494.   #-----------------------------------------------------------------------------
  1495.  
  1496.   def GetArguments():
  1497.  
  1498.     cn = os.path.basename(sys.argv[0])
  1499.  
  1500.     Parser = argparse.ArgumentParser(
  1501.       prog=cn,
  1502.       add_help=False,
  1503.       description='Stitches a series of screenshots of a Facebook thread.'
  1504.     )
  1505.  
  1506.     Parser.add_argument(
  1507.         '-h', '--help',
  1508.         dest='Help', action='store_true',
  1509.         help='Display this message and exit.')
  1510.     Parser.add_argument(
  1511.         '-d', '--dir', metavar='DIR',
  1512.         dest='Dir', default='',
  1513.         help=('Specify the directory where the screenshot image files may ' +
  1514.             'be found. The default is the current directory.'))
  1515.     Parser.add_argument(
  1516.         '-f', '--name-format', metavar='FMT',
  1517.         dest='NameFormat', default='%02d.png',
  1518.         help=('Specify the C format string for the numbered image ' +
  1519.             'file names (starting at 1). The default is "%%02d.png".'))
  1520.     Parser.add_argument(
  1521.         '-g', '--gutter',
  1522.         dest='Gutter', type=int, default=12,
  1523.         help='Specify the space in pixels to insert between columns.')
  1524.     Parser.add_argument(
  1525.         '-y', '--height',
  1526.         dest='Height', type=int,
  1527.         help=('Specify the target height in pixels of the output image.' +
  1528.             'This may be used correct the appearance of an unsightly short ' +
  1529.             'column.'))
  1530.     Parser.add_argument(
  1531.         '-m', '--margin',
  1532.         dest='Margin', type=int, default=16,
  1533.         help=('Specify the space in pixels to put between the image edges ' +
  1534.             'and the comment columns.'))
  1535.     Parser.add_argument(
  1536.         '-r', '--remove-ncm',
  1537.         dest='RemoveNCM', action='store_true',
  1538.         help='Remove the new comment marker.')
  1539.     Parser.add_argument(
  1540.         '-t', '--tolerance',
  1541.         dest='Tolerance', type=int, default=3,
  1542.         help=('Specify the maximum difference per channel to accept ' +
  1543.             'two pixels as matching.'))
  1544.     Parser.add_argument(
  1545.         '-q', '--quiet',
  1546.         dest='Quiet', action='store_true',
  1547.         help='Suppress all output.')
  1548.     Parser.add_argument(
  1549.         '-v', '--verbose',
  1550.         dest='Verbose', action='store_true',
  1551.         help='Display detailed metrics and progress information.')
  1552.     Parser.add_argument(
  1553.         '-V', '--version',
  1554.         dest='Version', action='store_true',
  1555.         help='Display version and exit.')
  1556.     Parser.add_argument(
  1557.         'NumScreenshots', metavar='number-of-screenshots',
  1558.         type=int,
  1559.         help=('Specify the number of screenshot files to load. If the ' +
  1560.             'default format string is used, the files must be named ' +
  1561.             '01.png, 02.png, 03.png and so on.'))
  1562.     Parser.add_argument(
  1563.         'OutputFileName', metavar='output-file',
  1564.         help='Specify the name of the multicolumn PNG image file to output.')
  1565.  
  1566.     if '-h' in sys.argv or '--help' in sys.argv:
  1567.       Parser.print_help()
  1568.       print(
  1569.         '\nExamples:\n' +
  1570.         '  ' + cn + ' 29 mc.png\n' +
  1571.         '  ' + cn + ' --margin 12 --gutter 4 -f "scr_%03d.png" 110 mc.png\n' +
  1572.         '  ' + cn + ' --tolerance 5 --height 2440 --remove-ncm 42 mc.png\n' +
  1573.         '  ' + cn + ' -v -y 2440 -t 5 -g 4 22 mc.png'
  1574.       )
  1575.       sys.exit(0)
  1576.  
  1577.     if '-V' in sys.argv or '--version' in sys.argv:
  1578.       print(VersionStr)
  1579.       sys.exit(0)
  1580.  
  1581.     Args = Parser.parse_args()
  1582.  
  1583.     if Args.OutputFileName == '-':
  1584.       # Write multicolumn image to stdout.
  1585.       Args.IsQuiet = True
  1586.  
  1587.     setattr(Args, 'Verbosity', 0 if Args.Quiet else (2 if Args.Verbose else 1))
  1588.  
  1589.     if Args.Height is not None:
  1590.       CheckArgRange('-y/--height', Args.Height, 200, 32000)
  1591.     CheckArgRange('-m/--margin', Args.Margin, 0, 100)
  1592.     CheckArgRange('-g/--gutter', Args.Gutter, 0, 100)
  1593.     CheckArgRange('-t/--tolerance', Args.Tolerance, 0, 64)
  1594.     CheckArgRange('number-of-screenshots', Args.NumScreenshots, 1, 999)
  1595.  
  1596.     try:
  1597.       S = Args.NameFormat % (1)
  1598.     except (TypeError, ValueError):
  1599.       raise ArgError('argument -f/--name-format: Invalid format string.')
  1600.  
  1601.     return Args
  1602.  
  1603.   #-----------------------------------------------------------------------------
  1604.  
  1605.   Result = 0
  1606.   ErrMsg = ''
  1607.  
  1608.   CmdName = os.path.basename(sys.argv[0])
  1609.  
  1610.   try:
  1611.  
  1612.     Args = GetArguments()
  1613.  
  1614.     Verbosity = Args.Verbosity
  1615.  
  1616.     Margin = Args.Margin
  1617.     Gutter = Args.Gutter
  1618.     TargetHeight = Args.Height
  1619.     Tolerance = Args.Tolerance
  1620.     RemoveNCM = Args.RemoveNCM
  1621.  
  1622.     n = Args.NumScreenshots
  1623.     OutputFileName = Args.OutputFileName
  1624.  
  1625.     if Args.Dir != '' and not os.path.isdir(Args.Dir):
  1626.       raise ArgError('argument --dir must be a directory.')
  1627.  
  1628.     if os.path.isdir(OutputFileName):
  1629.       raise ArgError('argument output-file must not be a directory.')
  1630.     else:
  1631.       Base = os.path.basename(OutputFileName)
  1632.       if Base in ['', '.', '..', '~']:
  1633.         raise ArgError('argument output-file "' + OutputFileName +
  1634.             '" is invalid.')
  1635.  
  1636.     ImgNames = [
  1637.       os.path.join(Args.Dir, Args.NameFormat % (i)) for i in range(1, n + 1)
  1638.     ]
  1639.  
  1640.     if Verbosity >= 1:
  1641.       print('Loading ' + str(n) + ' images...')
  1642.  
  1643.     FirstImg = Image.open(ImgNames[0]).convert('RGB')
  1644.     FirstImgFmt = FirstImg.format
  1645.  
  1646.     if Verbosity >= 2:
  1647.       print('  Identifying thread strip window boundaries...')
  1648.  
  1649.     Bounds = FindStripBounds(FirstImg)
  1650.  
  1651.     if Verbosity >= 2:
  1652.       print('    Bounds: ' + PrettyBoundsStr(Bounds) + ' (pixel edges)')
  1653.  
  1654.     if Verbosity >= 2:
  1655.       print('  Loading and cropping subsequent images...')
  1656.  
  1657.     Imgs = [FirstImg.crop(Bounds)]
  1658.     FirstImg = None
  1659.     for Name in ImgNames[1:]:
  1660.       Imgs.append(Image.open(Name).crop(Bounds).convert('RGB'))
  1661.  
  1662.     if Verbosity >= 1:
  1663.       print('Finding overlaps (Tolerance = ' + str(Tolerance) + ')...')
  1664.  
  1665.     OverlapLists = []
  1666.  
  1667.     for i in range(n - 1):
  1668.       OverlapList = ImageVOverlaps(Imgs[i], Imgs[i + 1], Tolerance)
  1669.       if len(OverlapList) < 1:
  1670.         if Verbosity >= 1:
  1671.           print('  No overlap between %d and %d.' % (i + 1, i + 2))
  1672.       OverlapLists.append(OverlapList)
  1673.  
  1674.     Overlaps = MostLikelyOverlaps(OverlapLists)
  1675.     Imgs = NonOverlappingImages(Imgs, Overlaps)
  1676.     VStrip = tVStrip(Imgs)
  1677.     VStrip = VTrimmedVStrip(VStrip)
  1678.  
  1679.     if RemoveNCM:
  1680.       if Verbosity >= 2:
  1681.         print('Removing highlighting for new comments...')
  1682.       SuppressNewCommentsMarker(VStrip)
  1683.  
  1684.     if Verbosity >= 1:
  1685.       print('Finding and laying out comment spans...')
  1686.  
  1687.     CBM = FindCommentBreakMetrics(VStrip)
  1688.  
  1689.     if CBM is None:
  1690.       raise ScanningError('Failed to analyse image for comment metrics.')
  1691.  
  1692.     Left, Right, Bottom, FirstBreak = CBM
  1693.     CapHeight = Left + 1
  1694.  
  1695.     if Verbosity >= 2:
  1696.       print(('  Comment metrics: Left = %d, Right = %d, Bottom = %d' %
  1697.           (Left, Right, Bottom)))
  1698.       if FirstBreak is not None:
  1699.         print(('  First comment break: (%d..%d)' %
  1700.             (FirstBreak[0], FirstBreak[1])))
  1701.       else:
  1702.         print(('  No comment break detected!'))
  1703.  
  1704.     CmtExts = []
  1705.     y = 0
  1706.     CmtBreak = FirstBreak
  1707.  
  1708.     while CmtBreak is not None:
  1709.       CmtExts.append((y, CmtBreak[0]))
  1710.       y = CmtBreak[1]
  1711.       CmtBreak = FindCommentBreak(VStrip, (Left, y), Right, Bottom)
  1712.     if y < VStrip.Height:
  1713.       CmtExts.append((y, VStrip.Height))
  1714.  
  1715.     TailCmtHeights = [CE[1] - CE[0] for CE in CmtExts[1:]]
  1716.     if len(TailCmtHeights) >= 1:
  1717.       AvgCmtHeight = int(round(
  1718.           float(sum(TailCmtHeights)) / len(TailCmtHeights)))
  1719.     else:
  1720.       AvgCmtHeight = 10
  1721.  
  1722.     if TargetHeight is None:
  1723.       a = 4.0/3.0
  1724.       w = VStrip.Width + Gutter
  1725.       h = w * sqrt((float(VStrip.Height) / w) / a)
  1726.       h += 0.5 * AvgCmtHeight + 2 * CapHeight
  1727.       TargetHeight = int(round(h + 2 * Margin))
  1728.  
  1729.     if Verbosity >= 2:
  1730.       print(('  Flowing columns for a target height of %d pixels.' %
  1731.           (TargetHeight)))
  1732.  
  1733.     MCSize, Pastes = WrappedCommentRuns(
  1734.       CmtExts, CapHeight, Margin, Gutter, TargetHeight, VStrip.Width
  1735.     )
  1736.  
  1737.     LPos, LBox = Pastes[-1]
  1738.     LastColBottom = LPos[1] + LBox[3] - LBox[1]
  1739.     LastColFraction = float(LastColBottom) / (MCSize[1] - 2 * Margin)
  1740.     LCFStr = str(int(round(100.0 * LastColFraction))) + '%'
  1741.  
  1742.     if Verbosity >= 2:
  1743.       print('  Columns spanned: ' + str(len(Pastes)))
  1744.     if LastColFraction < 0.2:
  1745.       if Verbosity >= 1:
  1746.         print('  Note: The last column is only ' + LCFStr + ' filled!')
  1747.     elif LastColFraction < 0.7:
  1748.       if Verbosity >= 1:
  1749.         print('  Note: The last column is ' + LCFStr + ' filled.')
  1750.  
  1751.     ImgSizeStr = str(MCSize[0]) + 'x' + str(MCSize[1])
  1752.     AnImgSizeStr = ('an' if ImgSizeStr[0] == '8' else 'a') + ' ' + ImgSizeStr
  1753.  
  1754.     if MCSize[0] > 32000 or MCSize[1] > 32000:
  1755.       raise LimitError('Output image would be too large at ' +
  1756.           ImgSizeStr +'.')
  1757.  
  1758.     if Verbosity >= 1:
  1759.       print('Assembling ' + AnImgSizeStr + ' multicolumn image...')
  1760.     if Verbosity >= 2:
  1761.       print('  Aspect ratio: %.3g' % (float(MCSize[0]) / MCSize[1]))
  1762.  
  1763.     TopCap, BotCap = NewCapImgs(VStrip, FirstBreak, CapHeight)
  1764.  
  1765.     MC = Image.new("RGB", MCSize, PageBGColour)
  1766.     for i, (Pos, Box) in enumerate(Pastes):
  1767.       VStrip.PasteTo(MC, Pos, Box)
  1768.       if i > 0:
  1769.         MC.paste(TopCap, (Pos[0], Pos[1] - CapHeight))
  1770.       if i + 1 < len(Pastes):
  1771.         MC.paste(BotCap, (Pos[0], Pos[1] + Box[3] - Box[1]))
  1772.  
  1773.     Fmt = FirstImgFmt if FirstImgFmt is not None else 'PNG'
  1774.  
  1775.     Ext = os.path.splitext(OutputFileName)[1].lower()
  1776.  
  1777.     Fmt = 'JPEG' if Ext in ['.jpg', '.jpeg'] else Fmt
  1778.     Fmt = 'GIF' if Ext in ['.gif'] else Fmt
  1779.     Fmt = 'PNG' if Ext in ['.png'] else Fmt
  1780.     Fmt = 'BMP' if Ext in ['.bmp'] else Fmt
  1781.     Fmt = 'TIFF' if Ext in ['.tif', '.tiff'] else Fmt
  1782.  
  1783.     if Verbosity >= 1:
  1784.       print(('Saving %s %s image to "%s"...' %
  1785.           (AnImgSizeStr, Fmt, OutputFileName)))
  1786.  
  1787.     MC.save(OutputFileName, Fmt)
  1788.  
  1789.     if Verbosity >= 2:
  1790.       print('Done!')
  1791.  
  1792.   except (ArgError) as E:
  1793.  
  1794.     ErrMsg = 'error: ' + str(E)
  1795.     Result = 2
  1796.  
  1797.   except (FileError) as E:
  1798.  
  1799.     ErrMsg = str(E)
  1800.     Result = 3
  1801.  
  1802.   except (LimitError) as E:
  1803.  
  1804.     ErrMsg = str(E)
  1805.     Result = 4
  1806.  
  1807.   except (ScanningError) as E:
  1808.  
  1809.     ErrMsg = str(E)
  1810.     Result = 5
  1811.  
  1812.   except (Exception) as E:
  1813.  
  1814.     exc_type, exc_value, exc_traceback = sys.exc_info()
  1815.     ErrLines = traceback.format_exc().splitlines()
  1816.     ErrMsg = 'Unhandled exception:\n' + '\n'.join(ErrLines)
  1817.     Result = 1
  1818.  
  1819.   if ErrMsg != '':
  1820.     print(CmdName + ': ' + ErrMsg, file=sys.stderr)
  1821.  
  1822.   return Result
  1823.  
  1824.  
  1825. #-------------------------------------------------------------------------------
  1826. # Command line trigger
  1827. #-------------------------------------------------------------------------------
  1828.  
  1829.  
  1830. if __name__ == '__main__':
  1831.   sys.exit(Main())
  1832.  
  1833.  
  1834. #-------------------------------------------------------------------------------
  1835. # End
  1836. #-------------------------------------------------------------------------------
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement