build-album.py

import os
import math

import cv2
import numpy as np
from PIL import Image, ImageOps
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas

SRC = "/home/ubuntu/pops"
OUT = "/home/ubuntu/pops/corrected"
PDF = "/home/ubuntu/pops/album.pdf"

CAPTIONS = [
    ("PXL_20260825_015133532.jpg",
     "Gilded four-panel screen painting of peonies",
     "Ink and colour on gold-leaf ground, mounted in a slim dark frame. Chinese "
     "characters at left read \"fu gui\" (wealth and honour), with red seal marks."),
    ("PXL_20260825_015212624.jpg",
     "Ceramic tile panel after Vel\u00e1zquez, \"The Waterseller of Seville\"",
     "Nine hand-coloured tiles in a moulded timber frame with a brass plaque "
     "inscribed \"Velazquez\"; a decorative reproduction of the c.1620 painting."),
    ("PXL_20260825_015220278.jpg",
     "Cast metal wall plaque: \"Romeo e Giulietta \u2013 Verona\"",
     "Bronze-finish relief in a scroll-shaped surround showing the balcony scene, "
     "titled in the lower right of the field. Typical Italian souvenir plaque."),
    ("PXL_20260825_015352098.jpg",
     "Wrought-metal wall art: woman with basket and bamboo",
     "Openwork panel of twisted and cut sheet metal depicting a kimono-clad figure "
     "carrying a yoke and flower basket beneath bamboo. Mid-century style."),
    ("PXL_20260825_015408216.jpg",
     "Metal wall panel \u2014 held detail view",
     "Same openwork panel photographed off the wall, showing the wire-formed "
     "outlines and cut-leaf bamboo in raking light."),
    ("PXL_20260825_015418525.jpg",
     "Carved hardwood mask with radiating headdress",
     "Dark striped timber (ebony/sono style) carved as a serene female face with "
     "pierced crown, floral crest and disc earrings; Balinese/Indonesian in style."),
    ("PXL_20260825_015428086.jpg",
     "Framed oil painting: bush creek below a bluff",
     "Impasto landscape with two small figures on the sandy bank, signed lower "
     "left, in a broad gilt frame with linen slip."),
    ("PXL_20260825_015435295.jpg",
     "Framed oil painting: gum trees along a creek",
     "Australian bush scene in blues and olive greens, signed lower left, in a "
     "dark timber frame with linen mount."),
    ("PXL_20260825_015455011.jpg",
     "Cut-velvet wall hanging: bird, lotus and waves",
     "Carved-pile textile in mushroom, plum and grey tones, fringed at the foot and "
     "hung from a turned white rod with a corded valance."),
    ("PXL_20260825_015504786.jpg",
     "Feather picture: bird on a weathered rock with blossom",
     "Shadow-box frame with a collage of dyed feathers forming the bird, rock and "
     "prunus. Inscribed \"chun guang ming mei\" (bright, charming spring light) with "
     "a red seal."),
    ("PXL_20260825_015509172.jpg",
     "Feather picture: kingfisher over autumn lotus",
     "Tall shadow-box panel; iridescent blue feathers form the kingfisher above lotus "
     "leaves and a white bloom. Inscribed \"bi tang qiu he\" (autumn lotus on the "
     "green pond)."),
    ("PXL_20260825_015513684.jpg",
     "Feather picture: sparrow on a blossoming plum branch",
     "Companion shadow-box panel with feather-work sparrow and applied plum blossom, "
     "signed with characters and a seal at lower right."),
    ("PXL_20260825_015537224.jpg",
     "Large ceramic peacock figure",
     "Near life-size white glazed peacock with a long moulded tail sweeping the floor, "
     "perched on a brown rockwork base."),
    ("PXL_20260825_015622599.jpg",
     "Bone-china shamrock bud vase",
     "Slender baluster vase painted with trailing green shamrocks and finished with "
     "gilt bands at rim and foot \u2014 an Irish china pattern."),
    ("PXL_20260825_015630036.jpg",
     "White ceramic polar bear figure",
     "Stylised recumbent bear in a plain white glaze."),
    ("PXL_20260825_015635616.jpg",
     "Four-panel table screen with birds and blossom",
     "Hinged miniature screen in a dark hardwood frame; inset panels painted with "
     "prunus, peony and perched birds above pierced fretwork feet."),
    ("PXL_20260825_015640088.jpg",
     "Table screen \u2014 angled view",
     "Same screen seen from the side, showing the hinges, the reverse landscape "
     "panel and the carved base."),
    ("PXL_20260825_015653979.jpg",
     "Dolphin group on a driftwood base",
     "Three smooth white dolphins mounted on a natural burl/driftwood plinth, with "
     "a maker's medallion on the front edge."),
    ("PXL_20260825_015709096.jpg",
     "Kneeling archer figure, Terracotta Army style",
     "Pale composition figure of an armoured kneeling warrior with topknot; a "
     "reproduction of the Xi'an terracotta warriors."),
    ("PXL_20260825_015743230.jpg",
     "Ivory-coloured figure of a lady with a flower",
     "Resin or composition figurine of a robed woman holding a chrysanthemum, on a "
     "turned dark wood stand; a small brass bud vase alongside."),
    ("PXL_20260825_015822776.jpg",
     "Carved wooden drummer figure",
     "Seated musician in dark striped hardwood playing a hand drum, on a black "
     "lacquered display plinth; Balinese/Indonesian carving style."),
    ("EXTRA_syd_mather.jpg",
     "Framed oil painting: figure and dog under blossoming trees",
     "Impressionistic impasto scene in blues, greys and pink, a woman with a parasol "
     "walking a small dog along a sunlit path. Signed lower left \"Syd Mather\"; "
     "distressed timber frame with a gilt inner slip."),
]

TITLE = "The Collection"
SUBTITLE = "A photographic album"


def deskew_angle(img, max_deg=6.0):
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    scale = 1000.0 / max(gray.shape)
    small = cv2.resize(gray, None, fx=scale, fy=scale)
    edges = cv2.Canny(small, 60, 180)
    lines = cv2.HoughLinesP(edges, 1, np.pi / 360, 100,
                            minLineLength=small.shape[1] // 4, maxLineGap=20)
    if lines is None:
        return 0.0
    angles = []
    weights = []
    for x1, y1, x2, y2 in lines[:, 0]:
        dx, dy = x2 - x1, y2 - y1
        length = math.hypot(dx, dy)
        a = math.degrees(math.atan2(dy, dx))
        if a > 90:
            a -= 180
        if a < -90:
            a += 180
        if abs(a) <= max_deg:            # near-horizontal
            angles.append(a)
            weights.append(length)
        elif abs(abs(a) - 90) <= max_deg:  # near-vertical
            angles.append(a - 90 if a > 0 else a + 90)
            weights.append(length)
    if not angles:
        return 0.0
    angles = np.array(angles)
    weights = np.array(weights)
    # weighted median
    order = np.argsort(angles)
    angles, weights = angles[order], weights[order]
    cw = np.cumsum(weights)
    med = angles[np.searchsorted(cw, cw[-1] / 2.0)]
    return float(med) if abs(med) <= max_deg else 0.0


def rotate_keep(img, angle):
    h, w = img.shape[:2]
    m = cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1.0)
    rot = cv2.warpAffine(img, m, (w, h), flags=cv2.INTER_CUBIC,
                         borderMode=cv2.BORDER_REPLICATE)
    # crop the largest axis-aligned rect free of border artefacts
    a = math.radians(abs(angle))
    if a > 0:
        cw = w * math.cos(a) - h * math.sin(a) if w >= h else w
        ch = h * math.cos(a) - w * math.sin(a) if h >= w else h
        scale = min(1.0, max(0.85, min(cw / w if cw > 0 else 1, ch / h if ch > 0 else 1)))
        nw, nh = int(w * scale), int(h * scale)
        x0, y0 = (w - nw) // 2, (h - nh) // 2
        rot = rot[y0:y0 + nh, x0:x0 + nw]
    return rot


def process():
    os.makedirs(OUT, exist_ok=True)
    report = []
    for name, _, _ in CAPTIONS:
        src = os.path.join(SRC, name)
        pil = ImageOps.exif_transpose(Image.open(src)).convert("RGB")
        img = cv2.cvtColor(np.array(pil), cv2.COLOR_RGB2BGR)
        ang = deskew_angle(img)
        if abs(ang) >= 0.3:
            img = rotate_keep(img, ang)
        dst = os.path.join(OUT, name)
        out = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
        out.thumbnail((2200, 2200), Image.LANCZOS)
        out.save(dst, quality=88)
        report.append((name, round(ang, 2), out.size))
    return report


def wrap(c, text, font, size, max_w):
    c.setFont(font, size)
    words, lines, cur = text.split(), [], ""
    for w in words:
        t = (cur + " " + w).strip()
        if c.stringWidth(t, font, size) <= max_w:
            cur = t
        else:
            lines.append(cur)
            cur = w
    if cur:
        lines.append(cur)
    return lines


def build_pdf():
    W, H = A4
    c = canvas.Canvas(PDF, pagesize=A4)
    c.setTitle(TITLE)

    # title page
    c.setFont("Helvetica-Bold", 30)
    c.drawCentredString(W / 2, H - 90 * mm, TITLE)
    c.setFont("Helvetica", 14)
    c.drawCentredString(W / 2, H - 104 * mm, SUBTITLE)
    c.setFont("Helvetica-Oblique", 10)
    c.drawCentredString(W / 2, H - 118 * mm,
                        "%d photographs \u2014 artworks, wall pieces and ornaments"
                        % len(CAPTIONS))
    c.setFont("Helvetica", 8)
    c.drawCentredString(W / 2, 25 * mm,
                        "Captions describe what is visible in each photograph; "
                        "no attribution, age or value is implied.")
    c.showPage()

    margin = 18 * mm
    for i, (name, title, note) in enumerate(CAPTIONS, 1):
        path = os.path.join(OUT, name)
        im = Image.open(path)
        iw, ih = im.size
        avail_w = W - 2 * margin
        avail_h = H - 2 * margin - 34 * mm
        s = min(avail_w / iw, avail_h / ih)
        dw, dh = iw * s, ih * s
        x = (W - dw) / 2
        y = H - margin - 8 * mm - dh
        c.drawImage(path, x, y, dw, dh)
        c.setStrokeColorRGB(0.75, 0.75, 0.75)
        c.setLineWidth(0.6)
        c.rect(x, y, dw, dh)

        ty = y - 9 * mm
        c.setFillColorRGB(0, 0, 0)
        for line in wrap(c, "%d. %s" % (i, title), "Helvetica-Bold", 12, avail_w):
            c.setFont("Helvetica-Bold", 12)
            c.drawString(margin, ty, line)
            ty -= 5.5 * mm
        ty -= 1 * mm
        c.setFillColorRGB(0.25, 0.25, 0.25)
        for line in wrap(c, note, "Helvetica", 9.5, avail_w):
            c.setFont("Helvetica", 9.5)
            c.drawString(margin, ty, line)
            ty -= 4.6 * mm
        c.setFillColorRGB(0.55, 0.55, 0.55)
        c.setFont("Helvetica", 7.5)
        c.drawRightString(W - margin, 10 * mm, "%d / %d" % (i, len(CAPTIONS)))
        c.showPage()
    c.save()


if __name__ == "__main__":
    for row in process():
        print(row)
    build_pdf()
    print("PDF:", PDF, os.path.getsize(PDF))