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))