Add museum map reveal, artifact path puzzle, and homelab media tooling.
Puzzle 1 crossfades to the stitched map with a link to /museum-path; puzzle 2 is a touch-friendly drag-and-order challenge with PDF extract scripts for map and artifact assets.
This commit is contained in:
Executable
+150
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract the 16 museum-map artifact images from EgyptEscapeRoomPrintables.pdf."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import fitz # PyMuPDF
|
||||
except ImportError:
|
||||
print("PyMuPDF is required: pip install pymupdf", file=sys.stderr)
|
||||
raise SystemExit(1) from None
|
||||
|
||||
from PIL import Image
|
||||
|
||||
# Identified by xref on pages 9–10 (puzzle 5 map). Slugs match the answer sheet.
|
||||
ARTIFACTS: list[dict[str, int | str]] = [
|
||||
{"page": 9, "xref": 56, "slug": "double-crown", "name": "Double Crown"},
|
||||
{"page": 9, "xref": 57, "slug": "obelisk", "name": "Obelisk"},
|
||||
{"page": 9, "xref": 63, "slug": "goddess-isis", "name": "Goddess Isis"},
|
||||
{"page": 9, "xref": 53, "slug": "canopic-jars", "name": "Canopic Jars"},
|
||||
{"page": 9, "xref": 61, "slug": "howard-carter-excavating-king-tut", "name": "Howard Carter excavating King Tut"},
|
||||
{"page": 9, "xref": 54, "slug": "anubis", "name": "Anubis"},
|
||||
{"page": 9, "xref": 58, "slug": "bust-of-nefertiti", "name": "Bust of Nefertiti"},
|
||||
{"page": 9, "xref": 60, "slug": "god-osiris", "name": "God Osiris"},
|
||||
{"page": 10, "xref": 68, "slug": "pyramid-of-giza", "name": "Pyramid of Giza"},
|
||||
{"page": 10, "xref": 75, "slug": "temple-of-hatshepsut", "name": "Temple of Hatshepsut"},
|
||||
{"page": 10, "xref": 72, "slug": "map-of-the-nile", "name": "Map of the Nile"},
|
||||
{"page": 10, "xref": 67, "slug": "egyptian-painting", "name": "Egyptian painting"},
|
||||
{"page": 10, "xref": 74, "slug": "sarcophagus", "name": "Sarcophagus"},
|
||||
{"page": 10, "xref": 70, "slug": "statue-of-hatshepsut", "name": "Statue of Hatshepsut"},
|
||||
{"page": 10, "xref": 76, "slug": "step-pyramid", "name": "Step Pyramid"},
|
||||
{"page": 10, "xref": 71, "slug": "mastaba", "name": "Mastaba"},
|
||||
]
|
||||
|
||||
|
||||
def find_image_info(page: fitz.Page, xref: int) -> dict:
|
||||
for info in page.get_image_info(xrefs=True):
|
||||
if info["xref"] == xref:
|
||||
return info
|
||||
raise ValueError(f"Image xref {xref} not found on page {page.number + 1}")
|
||||
|
||||
|
||||
def render_clip(page: fitz.Page, bbox: tuple[float, float, float, float], dpi: float) -> Image.Image:
|
||||
matrix = fitz.Matrix(dpi / 72.0, dpi / 72.0)
|
||||
pixmap = page.get_pixmap(matrix=matrix, clip=fitz.Rect(bbox), alpha=False)
|
||||
return Image.open(io.BytesIO(pixmap.tobytes("png")))
|
||||
|
||||
|
||||
def extract_embedded(doc: fitz.Document, xref: int) -> tuple[Image.Image, str]:
|
||||
data = doc.extract_image(xref)
|
||||
ext = data["ext"]
|
||||
image = Image.open(io.BytesIO(data["image"]))
|
||||
|
||||
smask = data.get("smask")
|
||||
if smask:
|
||||
mask_data = doc.extract_image(smask)
|
||||
mask = Image.open(io.BytesIO(mask_data["image"])).convert("L")
|
||||
if mask.size != image.size:
|
||||
mask = mask.resize(image.size, Image.Resampling.LANCZOS)
|
||||
rgba = image.convert("RGBA")
|
||||
rgba.putalpha(mask)
|
||||
background = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
|
||||
image = Image.alpha_composite(background, rgba).convert("RGB")
|
||||
ext = "png"
|
||||
|
||||
return image, ext
|
||||
|
||||
|
||||
def extract_artifacts(
|
||||
pdf_path: str,
|
||||
output_dir: Path,
|
||||
*,
|
||||
dpi: float = 300,
|
||||
mode: str = "render",
|
||||
) -> list[dict[str, object]]:
|
||||
doc = fitz.open(pdf_path)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
manifest: list[dict[str, object]] = []
|
||||
|
||||
try:
|
||||
for artifact in ARTIFACTS:
|
||||
page_number = int(artifact["page"])
|
||||
xref = int(artifact["xref"])
|
||||
slug = str(artifact["slug"])
|
||||
name = str(artifact["name"])
|
||||
page = doc[page_number - 1]
|
||||
info = find_image_info(page, xref)
|
||||
|
||||
if mode == "render":
|
||||
image = render_clip(page, info["bbox"], dpi)
|
||||
filename = f"{slug}.png"
|
||||
image.save(output_dir / filename, optimize=True)
|
||||
else:
|
||||
image, ext = extract_embedded(doc, xref)
|
||||
filename = f"{slug}.{ext}"
|
||||
if ext == "jpeg":
|
||||
image.save(output_dir / filename, quality=92, optimize=True)
|
||||
else:
|
||||
image.save(output_dir / filename, optimize=True)
|
||||
|
||||
manifest.append(
|
||||
{
|
||||
"slug": slug,
|
||||
"name": name,
|
||||
"page": page_number,
|
||||
"xref": xref,
|
||||
"file": filename,
|
||||
"width": image.width,
|
||||
"height": image.height,
|
||||
"bbox": info["bbox"],
|
||||
}
|
||||
)
|
||||
print(f"Wrote {filename} ({image.width}x{image.height})")
|
||||
finally:
|
||||
doc.close()
|
||||
|
||||
manifest_path = output_dir / "manifest.json"
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"Wrote {manifest_path}")
|
||||
return manifest
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pdf", help="Path to EgyptEscapeRoomPrintables.pdf")
|
||||
parser.add_argument(
|
||||
"output_dir",
|
||||
nargs="?",
|
||||
default="public/media/artifacts",
|
||||
help="Output directory (default: public/media/artifacts)",
|
||||
)
|
||||
parser.add_argument("--dpi", type=float, default=300, help="Render DPI for --mode render (default: 300)")
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=("render", "embedded"),
|
||||
default="render",
|
||||
help="render: clip from page (default, best for illustrations); embedded: raw image bytes",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
extract_artifacts(args.pdf, Path(args.output_dir), dpi=args.dpi, mode=args.mode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract puzzle 5 map pages from the printables PDF and stitch into one image."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import sys
|
||||
|
||||
try:
|
||||
import fitz # PyMuPDF
|
||||
except ImportError:
|
||||
print("PyMuPDF is required: pip install pymupdf", file=sys.stderr)
|
||||
raise SystemExit(1) from None
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
|
||||
def render_page(doc: fitz.Document, page_number: int, dpi: float) -> Image.Image:
|
||||
page = doc[page_number - 1]
|
||||
zoom = dpi / 72.0
|
||||
matrix = fitz.Matrix(zoom, zoom)
|
||||
pixmap = page.get_pixmap(matrix=matrix, alpha=False)
|
||||
return Image.open(io.BytesIO(pixmap.tobytes("png")))
|
||||
|
||||
|
||||
def trim_white(image: Image.Image, *, pad: int = 8, threshold: int = 245) -> Image.Image:
|
||||
gray = image.convert("L")
|
||||
mask = gray.point(lambda value: 255 if value > threshold else 0)
|
||||
bbox = mask.getbbox()
|
||||
if bbox is None:
|
||||
return image
|
||||
|
||||
left, top, right, bottom = bbox
|
||||
left = max(0, left - pad)
|
||||
top = max(0, top - pad)
|
||||
right = min(image.width, right + pad)
|
||||
bottom = min(image.height, bottom + pad)
|
||||
return image.crop((left, top, right, bottom))
|
||||
|
||||
|
||||
def match_height(left: Image.Image, right: Image.Image) -> tuple[Image.Image, Image.Image]:
|
||||
height = max(left.height, right.height)
|
||||
if left.height != height:
|
||||
canvas = Image.new("RGB", (left.width, height), "white")
|
||||
canvas.paste(left, (0, 0))
|
||||
left = canvas
|
||||
if right.height != height:
|
||||
canvas = Image.new("RGB", (right.width, height), "white")
|
||||
canvas.paste(right, (0, 0))
|
||||
right = canvas
|
||||
return left, right
|
||||
|
||||
|
||||
def stitch_map(
|
||||
pdf_path: str,
|
||||
*,
|
||||
left_page: int = 9,
|
||||
right_page: int = 10,
|
||||
dpi: float = 200,
|
||||
) -> Image.Image:
|
||||
doc = fitz.open(pdf_path)
|
||||
try:
|
||||
if doc.page_count < max(left_page, right_page):
|
||||
raise ValueError(
|
||||
f"PDF has {doc.page_count} pages; need at least page {max(left_page, right_page)}"
|
||||
)
|
||||
|
||||
left = trim_white(render_page(doc, left_page, dpi))
|
||||
right = trim_white(render_page(doc, right_page, dpi))
|
||||
left, right = match_height(left, right)
|
||||
|
||||
combined = Image.new("RGB", (left.width + right.width, left.height), "white")
|
||||
combined.paste(left, (0, 0))
|
||||
combined.paste(right, (left.width, 0))
|
||||
return combined
|
||||
finally:
|
||||
doc.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pdf", help="Path to EgyptEscapeRoomPrintables.pdf")
|
||||
parser.add_argument("output", help="Output image path (.jpg or .png)")
|
||||
parser.add_argument("--left-page", type=int, default=9, help="Left half page number (default: 9)")
|
||||
parser.add_argument("--right-page", type=int, default=10, help="Right half page number (default: 10)")
|
||||
parser.add_argument("--dpi", type=float, default=200, help="Render DPI (default: 200)")
|
||||
args = parser.parse_args()
|
||||
|
||||
image = stitch_map(
|
||||
args.pdf,
|
||||
left_page=args.left_page,
|
||||
right_page=args.right_page,
|
||||
dpi=args.dpi,
|
||||
)
|
||||
|
||||
output = args.output.lower()
|
||||
if output.endswith(".png"):
|
||||
image.save(args.output, optimize=True)
|
||||
else:
|
||||
image.save(args.output, quality=90, optimize=True)
|
||||
|
||||
print(f"Wrote {args.output} ({image.width}x{image.height})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,2 @@
|
||||
pymupdf>=1.24
|
||||
Pillow>=10.0
|
||||
@@ -3,6 +3,7 @@ set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SOURCE_DIR="${SOURCE_DIR:-/mnt/lockbox/storage/Documents/Home School Projects/Egyptian Escape Room}"
|
||||
PDF_SOURCE="${PDF_SOURCE:-$SOURCE_DIR/EgyptEscapeRoomPrintables.pdf}"
|
||||
VIDEO_SOURCE="${VIDEO_SOURCE:-$SOURCE_DIR/Ted Escape Room 1-5.mp4}"
|
||||
POP_SOURCE="${POP_SOURCE:-$SOURCE_DIR/celebration-pop.mp3}"
|
||||
HORN_SOURCE="${HORN_SOURCE:-$SOURCE_DIR/celebration-horn.mp3}"
|
||||
@@ -10,7 +11,7 @@ INCORRECT_SOURCE="${INCORRECT_SOURCE:-$SOURCE_DIR/incorrect.mp3}"
|
||||
CONFIRM_SOURCE="${CONFIRM_SOURCE:-$SOURCE_DIR/unlock-confirm.mp3}"
|
||||
DEST_DIR="$ROOT/public/media"
|
||||
|
||||
for f in "$VIDEO_SOURCE" "$POP_SOURCE" "$HORN_SOURCE" "$INCORRECT_SOURCE" "$CONFIRM_SOURCE"; do
|
||||
for f in "$PDF_SOURCE" "$VIDEO_SOURCE" "$POP_SOURCE" "$HORN_SOURCE" "$INCORRECT_SOURCE" "$CONFIRM_SOURCE"; do
|
||||
if [[ ! -f "$f" ]]; then
|
||||
echo "Media file not found: $f" >&2
|
||||
exit 1
|
||||
@@ -23,6 +24,8 @@ cp "$POP_SOURCE" "$DEST_DIR/celebration-pop.mp3"
|
||||
cp "$HORN_SOURCE" "$DEST_DIR/celebration-horn.mp3"
|
||||
cp "$INCORRECT_SOURCE" "$DEST_DIR/incorrect.mp3"
|
||||
cp "$CONFIRM_SOURCE" "$DEST_DIR/unlock-confirm.mp3"
|
||||
python3 "$ROOT/scripts/extract-artifact-room-map.py" "$PDF_SOURCE" "$DEST_DIR/artifact-room-map.jpg"
|
||||
python3 "$ROOT/scripts/extract-artifact-images.py" "$PDF_SOURCE" "$DEST_DIR/artifacts"
|
||||
cp "$ROOT/public/media/config.json.example" "$DEST_DIR/config.json"
|
||||
|
||||
echo "Copied media to $DEST_DIR/"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SOURCE_DIR="${SOURCE_DIR:-/mnt/user/storage/Documents/Home School Projects/Egyptian Escape Room}"
|
||||
PDF_SOURCE="${PDF_SOURCE:-$SOURCE_DIR/EgyptEscapeRoomPrintables.pdf}"
|
||||
VIDEO_SOURCE="${VIDEO_SOURCE:-$SOURCE_DIR/Ted Escape Room 1-5.mp4}"
|
||||
POP_SOURCE="${POP_SOURCE:-$SOURCE_DIR/celebration-pop.mp3}"
|
||||
HORN_SOURCE="${HORN_SOURCE:-$SOURCE_DIR/celebration-horn.mp3}"
|
||||
@@ -9,7 +11,7 @@ INCORRECT_SOURCE="${INCORRECT_SOURCE:-$SOURCE_DIR/incorrect.mp3}"
|
||||
CONFIRM_SOURCE="${CONFIRM_SOURCE:-$SOURCE_DIR/unlock-confirm.mp3}"
|
||||
DEST="${DEST:-/mnt/user/appdata/egyptian-escape-room/public}"
|
||||
|
||||
for f in "$VIDEO_SOURCE" "$POP_SOURCE" "$HORN_SOURCE" "$INCORRECT_SOURCE" "$CONFIRM_SOURCE"; do
|
||||
for f in "$PDF_SOURCE" "$VIDEO_SOURCE" "$POP_SOURCE" "$HORN_SOURCE" "$INCORRECT_SOURCE" "$CONFIRM_SOURCE"; do
|
||||
if [[ ! -f "$f" ]]; then
|
||||
echo "Media file not found: $f" >&2
|
||||
exit 1
|
||||
@@ -22,9 +24,12 @@ cp "$POP_SOURCE" "$DEST/celebration-pop.mp3"
|
||||
cp "$HORN_SOURCE" "$DEST/celebration-horn.mp3"
|
||||
cp "$INCORRECT_SOURCE" "$DEST/incorrect.mp3"
|
||||
cp "$CONFIRM_SOURCE" "$DEST/unlock-confirm.mp3"
|
||||
python3 "$ROOT/scripts/extract-artifact-room-map.py" "$PDF_SOURCE" "$DEST/artifact-room-map.jpg"
|
||||
python3 "$ROOT/scripts/extract-artifact-images.py" "$PDF_SOURCE" "$DEST/artifacts"
|
||||
cat >"$DEST/config.json" <<'EOF'
|
||||
{
|
||||
"unlockVideoUrl": "/media/unlock.mp4",
|
||||
"mapImageUrl": "/media/artifact-room-map.jpg",
|
||||
"incorrectUrl": "/media/incorrect.mp3",
|
||||
"unlockConfirmUrl": "/media/unlock-confirm.mp3",
|
||||
"celebrationPopUrl": "/media/celebration-pop.mp3",
|
||||
|
||||
Reference in New Issue
Block a user