Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,36 @@ The sponsor summary is defined in a separate partial `themes/pybcn_theme/layouts
About the creation of new sponsors, the `themes/pybcn_theme/archetypes/sponsors.md` file contains the template Hugo will use when the editor runs the command `hugo new sponsors/my-new-sponsor.md`.


### Person photos

Every person photo is a square, cut once in the repository rather than on each
build. `bin/square-photos` does the cutting: it finds the face with OpenCV,
crops the largest square it can centre on that face, and resizes to at most
800px, which is what the 2x variant of a 400px grid slot needs. It never
enlarges, because a 200px photo blown up to 800 is the same picture in four
times the bytes.

```
pip install opencv-python pillow
bin/square-photos --dry-run # list what would change, change nothing
bin/square-photos # rewrite the photos in place
```

It is run by hand when a photo is added. The build does not run it and neither
does CI, so a contributor without OpenCV can still build the site.

Review the result before committing. Face detection is crude: it misses a face
in profile or in shadow, and it finds faces that are not there. When it misses,
the crop falls back to the upper middle of the picture, which is where a head
usually is. When the crop is wrong, the usual cause is the photo rather than
the crop: a full-length shot on a beach stays a full-length shot on a beach,
because the tool does not zoom in.

`bin/check-content` rejects a photo that is not square, one over 1200px, and
one over 1000 KB, and it reports the photos under 400px, which the build has
to enlarge and which look soft in the grid. Replacing the file is the only fix
for those.

### People pages implementation details: organizers, speakers...

There are three pages displaying people: PyBCN Organizers, PyLadiesBCN Organizers and PyLadiesBCN speakers. More can be added to display, for example, collaborators of other events, etc.
Expand Down
66 changes: 37 additions & 29 deletions bin/check-content
Original file line number Diff line number Diff line change
Expand Up @@ -153,37 +153,56 @@ def image_size(path):


def check_photos(people):
# A photo that is not square is cropped by the build rather than squashed,
# so it is not an error. It is reported once, with a count, because there
# are dozens of them and a line each would bury everything else.
cropped = []
"""Every person photo has to be a square the site can serve as it is.

The square is cut once, in the repository, by bin/square-photos, so the
build only resizes. A photo that is not square would be cropped by the
build instead, by a rule that does not know where the face is, and nobody
would see the result before it was live.

The size limits are here for the pull request nobody reads closely: a
photo straight off a phone is several megabytes and tens of megapixels,
and it costs the repository that much for ever.
"""
small = []
for page_id, path in people.items():
data = front_matter(path) or {}
# A remote photo loads from a third party on every page that lists
# the person and tells that party who viewed it. The template no
# longer reads the field, so a value here would be ignored in silence.
if data.get("photo_url"):
error(path, "photo_url is not supported: put the file under "
"themes/pybcn_theme/assets/images/people/ and use 'photo'")
photo = data.get("photo")
if photo and not bare_file_name(path, "photo", photo):
continue
found = next((d / photo for d in PHOTO_DIRS if photo and (d / photo).exists()), None)
if photo and not found:
where = ", ".join(str(d.relative_to(ROOT)) for d in PHOTO_DIRS)
error(path, f"photo '{photo}' not found in any of: {where}")
elif found:
size = image_size(found)
if size and size[0] != size[1]:
cropped.append((photo, size))
if size and min(size) < PHOTO_SIDE:
small.append((photo, size))
report_cropped(cropped)
continue
if not found:
continue

size = found.stat().st_size
if size > PHOTO_MAX_BYTES:
error(path, f"photo '{photo}' is {size // 1024} KB, over the "
f"{PHOTO_MAX_BYTES // 1024} KB limit. Run "
f"bin/square-photos, or save it smaller.")

dimensions = image_size(found)
if not dimensions:
continue
width, height = dimensions
if width != height:
error(path, f"photo '{photo}' is {width}x{height}, not square. "
f"Run bin/square-photos, which crops it on the face.")
elif width > PHOTO_MAX_SIDE:
error(path, f"photo '{photo}' is {width}px square, over the "
f"{PHOTO_MAX_SIDE}px limit. The site never serves more "
f"than 800. Run bin/square-photos.")
if min(width, height) < PHOTO_SIDE:
small.append((photo, (width, height)))
report_small(small)


PHOTO_SIDE = 400
PHOTO_MAX_SIDE = 1200
PHOTO_MAX_BYTES = 1_000_000


def report_small(small):
Expand All @@ -192,18 +211,7 @@ def report_small(small):
shown = ", ".join(f"{name} ({w}x{h})" for name, (w, h) in sorted(small)[:3])
note(f"{len(small)} of the person photos are smaller than {PHOTO_SIDE}px on "
f"their short side, so the build enlarges them and they look soft in "
f"the grid. Smallest: {shown}.")


def report_cropped(cropped):
if not cropped:
return
worst = sorted(cropped, key=lambda item: abs(item[1][0] / item[1][1] - 1), reverse=True)[:3]
shown = ", ".join(f"{name} ({w}x{h})" for name, (w, h) in worst)
note(f"{len(cropped)} of the person photos are not square, so the build "
f"crops them to fit the grid and part of each picture is lost. "
f"Furthest from square: {shown}. Crop one yourself to choose which "
f"part survives.")
f"the grid. Smallest: {shown}. Replacing the file is the only fix.")


def check_logos(sponsors):
Expand Down
208 changes: 208 additions & 0 deletions bin/square-photos
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""Crop the person photos to a square, centred on the face.

The site serves every person photo as a square. Until now the square was cut
at build time, which has two costs: the crop is chosen by a heuristic that
does not know where the face is, and the repository carries sources far larger
than anything the site ever serves, 38 MB of them.

This cuts the square once, in the repository, so the crop is reviewable in a
pull request like any other change, and the build only has to resize.

The crop is centred on the face when OpenCV finds one, and on the upper middle
when it does not, because a portrait that is taller than it is wide almost
always has the head in the top half.

Usage:
bin/square-photos --dry-run list what would change, change nothing
bin/square-photos rewrite the photos in place
bin/square-photos --side 800 pick the target side, default 800

Needs opencv-python and pillow, which are not needed to build the site. This
is run by hand when a photo is added, not by the build and not by CI.
"""

import argparse
import pathlib
import sys

try:
import cv2
from PIL import Image, ImageOps
except ImportError:
sys.exit("needs opencv-python and pillow: pip install opencv-python pillow")

ROOT = pathlib.Path(__file__).resolve().parent.parent
PHOTOS = ROOT / "themes" / "pybcn_theme" / "assets" / "images" / "people"
SUFFIXES = {".jpg", ".jpeg", ".png", ".webp"}

# 800px is what the 2x variant of a 400px grid slot needs. A source larger than
# that is never served at full size, so the extra pixels are weight and nothing
# else.
DEFAULT_SIDE = 800


def load(path):
"""Open a photo with its EXIF rotation applied.

One photo in the set carries Orientation 5. A browser honours that tag and
shows the picture upright, so the site looked right while every tool that
reads the pixels directly saw it on its side. Applying the rotation here
and dropping the tag leaves one truth instead of two.
"""
image = Image.open(path)
return ImageOps.exif_transpose(image)


def find_face(path):
"""Return (x, y, w, h) of the face, or None.

A Haar cascade returns several boxes for one portrait and most of them are
wrong. Neither obvious rule picks the right one on its own:

toni_espadas real face 953px at 5.40, a 59px box scored 6.85
ferran-jovell real face 2062px at 3.84, a 154px box scored 5.92
juan-bernardo-lince real face 170px at 0.18, an 88px box scored 1.69
alicia-morales real face 932px at 7.22, a 2091px box scored 1.72

adrian-garcia-riber real face 854px at 7.27, a 51px box scored 0.20

Taking the most confident box fails on the first three. Taking the largest
fails on alicia-morales. Dropping every box wider than 60% of the short
side fixes her and breaks adrian-garcia-riber, whose face really is 64% of
it, because the photo is a close selfie.

Width alone cannot tell a close selfie from a box over a torso. The score
can: the selfie scored 7.27 and the torso 1.72. So a wide box survives when
it is confident, and the largest of the survivors wins.

The cascade still misses a face in profile or in shadow, which is why the
caller has a fallback rather than an error.
"""
with load(path) as upright:
rgb = upright.convert("RGB")
import numpy
image = cv2.cvtColor(numpy.array(rgb), cv2.COLOR_RGB2BGR)
grey = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
grey = cv2.equalizeHist(grey)
cascade = cv2.CascadeClassifier(
cv2.data.haarcascades + "haarcascade_frontalface_default.xml")
faces, _levels, weights = cascade.detectMultiScale3(
grey, scaleFactor=1.1, minNeighbors=5, minSize=(40, 40),
outputRejectLevels=True)
if len(faces) == 0:
return None
limit = MAX_FACE_FRACTION * min(grey.shape[0], grey.shape[1])
plausible = [face for face, weight in zip(faces, weights)
if face[2] <= limit or weight >= MIN_WIDE_FACE_WEIGHT]
return max(plausible or list(faces), key=lambda f: f[2] * f[3])


# A square centred on a face near the edge does not fit, so the square has to
# shrink. It does not shrink below this, because a tight crop of a head with no
# shoulders looks like a mugshot next to the others in the grid.
MIN_FACE_MULTIPLE = 3.5

# A detection this wide, as a fraction of the short side, is suspect: a face
# that big is a close selfie, which happens, but so does a box over a whole
# torso, which is not a face at all.
MAX_FACE_FRACTION = 0.6

# What tells those two apart is the score. The selfie scored 7.27 and the torso
# scored 1.72, so a wide box is kept when it is confident and dropped when it
# is not.
MIN_WIDE_FACE_WEIGHT = 3.0


def crop_box(width, height, face):
"""Return the square box to keep, as (left, top, right, bottom).

The square is the largest one that fits, unless the face sits closer to an
edge than half of that: then it shrinks, so the face stays in the middle
rather than drifting to a corner. One photo in the set, a 2731x4096 portrait
with the head high in the frame, came out with the face a third of the way
down before this.
"""
side = min(width, height)
if face is not None:
fx, fy, fw, fh = face
centre_x = fx + fw / 2
centre_y = fy + fh / 2
fits_centred = 2 * min(centre_x, width - centre_x,
centre_y, height - centre_y)
floor = min(side, max(DEFAULT_SIDE, round(MIN_FACE_MULTIPLE * fh)))
side = max(min(side, round(fits_centred)), floor)
side = min(side, width, height)
else:
centre_x = width / 2
# The upper middle, not the middle: a portrait taller than it is wide
# has the head in the top half, and a centred crop cuts the forehead.
centre_y = height / 2 if width >= height else height * 0.4
left = round(centre_x - side / 2)
top = round(centre_y - side / 2)
left = max(0, min(left, width - side))
top = max(0, min(top, height - side))
return (left, top, left + side, top + side)


def process(path, side, dry_run):
with load(path) as image:
width, height = image.size
already_square = width == height
face = None if already_square and width <= side else find_face(path)
box = crop_box(width, height, face)
cropped_side = box[2] - box[0]

# Never enlarge. A 200px photo blown up to 800 is the same picture in
# four times the bytes, and the build already enlarges when it has to.
target = min(side, cropped_side)

rotated = image.getexif().get(274, 1) not in (1, None)
if already_square and width == target and not rotated:
return None

where = "face" if face is not None else "fallback"
note = (f"{width}x{height} -> {target}x{target}"
f" crop {cropped_side}px at {box[0]},{box[1]} ({where})")
if dry_run:
return note

out = image.convert("RGB") if image.mode in ("P", "RGBA", "LA") else image
out = out.crop(box)
if cropped_side != target:
out = out.resize((target, target), Image.LANCZOS)
if path.suffix.lower() in (".jpg", ".jpeg"):
out.save(path, "JPEG", quality=88, optimize=True, progressive=True)
elif path.suffix.lower() == ".png":
out.save(path, "PNG", optimize=True)
else:
out.save(path)
return note


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--side", type=int, default=DEFAULT_SIDE)
args = parser.parse_args()

changed = 0
before = after = 0
for path in sorted(PHOTOS.iterdir()):
if path.suffix.lower() not in SUFFIXES:
continue
before += path.stat().st_size
note = process(path, args.side, args.dry_run)
after += path.stat().st_size
if note:
changed += 1
print(f"{path.name}: {note}")

print(f"\n{changed} photos {'would change' if args.dry_run else 'changed'}.")
if not args.dry_run:
print(f"{before / 1048576:.1f} MB -> {after / 1048576:.1f} MB")
return 0


if __name__ == "__main__":
sys.exit(main())
8 changes: 4 additions & 4 deletions config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ unsafe=true

[params.social_items]
[params.social_items.twitter]
name = "Twitter"
icon = "fab fa-twitter-square fa-2x"
name = "X"
icon = "icon_x fa-2x"
link = "https://twitter.com/PyBCN"
[params.social_items.youtube]
name = "YouTube"
Expand All @@ -64,8 +64,8 @@ unsafe=true

[params.social_items_pyladies]
[params.social_items_pyladies.twitter]
name = "Twitter"
icon = "fab fa-twitter-square fa-2x"
name = "X"
icon = "icon_x fa-2x"
link = "https://twitter.com/PyLadiesBCN"
[params.social_items_pyladies.meetup]
name = "Meetup"
Expand Down
14 changes: 7 additions & 7 deletions content/pybcn_association/organizers.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,16 @@ layout: people
heroBackground: /images/photos/reg-desk-pyday-2019.jpg

people_levels:
- people_per_line: 3
- people_per_line: 5
people: [jordi-bosch, daniel-mesejo, yago-tafalla, mauricio-volcanes, sergi-ramirez]
name: The Management Board
- people_per_line: 4
name: Management Board (2026)
- people_per_line: 5
people: [alberto, david, ifosch, loris, natalia, rberenguel, xavi, alicia-morales, ferran-jovell]
name: The Permanent Committee
- people_per_line: 6
name: Permanent Committee
- people_per_line: 5
people: [elisabeth-ortega-carrasco, josep, lpmayos, nuria, jose-riera]
name: Our collaborators
name: Collaborators
- people_per_line: 5
people: [lpmayos-president, mireia, natalia-secretary]
name: Management Board 2018 - 2025
name: Management Board (2018 - 2025)
---
Binary file modified themes/pybcn_theme/assets/images/people/JavierSabariego.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified themes/pybcn_theme/assets/images/people/adria-blanco.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified themes/pybcn_theme/assets/images/people/afra-llado.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified themes/pybcn_theme/assets/images/people/albert-franzi.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified themes/pybcn_theme/assets/images/people/alberto-labarga.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified themes/pybcn_theme/assets/images/people/alex-molas.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified themes/pybcn_theme/assets/images/people/alicia-morales.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified themes/pybcn_theme/assets/images/people/alvaro_duran.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified themes/pybcn_theme/assets/images/people/angel.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified themes/pybcn_theme/assets/images/people/anton-caceres.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified themes/pybcn_theme/assets/images/people/camilo-chacon.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified themes/pybcn_theme/assets/images/people/cheuk-ting-ho.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified themes/pybcn_theme/assets/images/people/dani-mesejo.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified themes/pybcn_theme/assets/images/people/daniel_mesejo.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified themes/pybcn_theme/assets/images/people/david.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified themes/pybcn_theme/assets/images/people/diego-giaquinta.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified themes/pybcn_theme/assets/images/people/diego-gonzalez.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified themes/pybcn_theme/assets/images/people/edgar-riba.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified themes/pybcn_theme/assets/images/people/eloi.jpg
Binary file modified themes/pybcn_theme/assets/images/people/ferran-jovell.jpg
Binary file modified themes/pybcn_theme/assets/images/people/ferran_fabregas.png
Binary file modified themes/pybcn_theme/assets/images/people/guillermo_abad.jpg
Binary file modified themes/pybcn_theme/assets/images/people/jimena-escobar.jpg
Binary file modified themes/pybcn_theme/assets/images/people/jordi-mur.jpg
Binary file modified themes/pybcn_theme/assets/images/people/jordi.jpg
Binary file modified themes/pybcn_theme/assets/images/people/jose_haro.png
Binary file modified themes/pybcn_theme/assets/images/people/josep.jpg
Binary file modified themes/pybcn_theme/assets/images/people/juan-luis-cano.jpeg
Binary file modified themes/pybcn_theme/assets/images/people/julio-martinez.jpg
Binary file modified themes/pybcn_theme/assets/images/people/kevin-albes.jpg
Binary file modified themes/pybcn_theme/assets/images/people/loris.jpg
Binary file modified themes/pybcn_theme/assets/images/people/lpmayos.jpeg
Binary file modified themes/pybcn_theme/assets/images/people/manuel-gijon.jpg
Binary file modified themes/pybcn_theme/assets/images/people/marc-ramirez.jpeg
Binary file modified themes/pybcn_theme/assets/images/people/marina-palma.jpg
Binary file modified themes/pybcn_theme/assets/images/people/miquel-sarrias.jpeg
Binary file modified themes/pybcn_theme/assets/images/people/noe-casas.png
Binary file modified themes/pybcn_theme/assets/images/people/pavel-sulimov.png
Binary file modified themes/pybcn_theme/assets/images/people/peter_krupa.jpeg
Binary file modified themes/pybcn_theme/assets/images/people/rajdeep-pal.jpeg
Binary file modified themes/pybcn_theme/assets/images/people/ruben-afonso.png
Binary file modified themes/pybcn_theme/assets/images/people/selen-ozkan.jpg
Binary file modified themes/pybcn_theme/assets/images/people/sergi-ramirez.jpeg
Binary file modified themes/pybcn_theme/assets/images/people/sonia-estrade.jpeg
Binary file modified themes/pybcn_theme/assets/images/people/toni_espadas.jpg
Binary file modified themes/pybcn_theme/assets/images/people/vivek-sharma.jpg
Binary file modified themes/pybcn_theme/assets/images/people/xiang-xu.jpg
Loading
Loading