Attendance / fetch_section_faces.py
Awab-Elsadig's picture
Initialize standalone Attendance service repo
d7c862a
Raw
History Blame Contribute Delete
17.7 kB
"""
Dev/test helper: download one portrait per student in a course section into dataset/
and build a synthetic "class photo" by tiling those same images.
Roster source (first match wins):
1) --user-ids
2) GET /api/enrollments/section/:id/students with --api-url + --bearer-token
3) MySQL: course_enrollments for --section-id (or resolve via --course-code + --semester-name + --section-number)
Portraits: https://randomuser.me/ (not real student photos; 1:1 mapped to user_id for testing).
Examples:
python fetch_section_faces.py --section-id 26
python fetch_section_faces.py --course-code AH102 --semester-name "Fall 2025" --section-number 1 --env ../../.env
python fetch_section_faces.py --section-id 26 --api-url http://localhost:3001 --bearer-token YOUR_JWT
python fetch_section_faces.py --user-ids 57,7,8
python fetch_section_faces.py --audit-only
python fetch_section_faces.py --rebuild-composite-only --section-id 26
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
try:
from PIL import Image
except ImportError:
print("Install Pillow: pip install pillow", file=sys.stderr)
sys.exit(1)
try:
import pymysql
except ImportError:
pymysql = None
def load_env_file(path: Path) -> dict[str, str]:
out: dict[str, str] = {}
if not path.is_file():
return out
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
k, _, v = line.partition("=")
k, v = k.strip(), v.strip().strip('"').strip("'")
out[k] = v
return out
def fetch_user_ids_from_db(section_id: int, env: dict[str, str]) -> list[int]:
if pymysql is None:
raise RuntimeError("Install pymysql: pip install pymysql")
port = env.get("DB_PORT")
if not port:
raise RuntimeError("DB_PORT missing in .env")
conn = pymysql.connect(
host=env.get("DB_HOST", "localhost"),
port=int(port),
user=env.get("DB_USERNAME", "root"),
password=env.get("DB_PASSWORD", ""),
database=env.get("DB_DATABASE", "eduverse_db"),
cursorclass=pymysql.cursors.DictCursor,
)
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT DISTINCT user_id
FROM course_enrollments
WHERE section_id = %s AND enrollment_status = 'enrolled'
ORDER BY user_id
""",
(section_id,),
)
rows = cur.fetchall()
finally:
conn.close()
return [int(r["user_id"]) for r in rows]
def resolve_section_id_from_db(
course_code: str,
semester_name: str,
section_number: str,
env: dict[str, str],
) -> int:
"""Match course_sections row: AH102 + Fall 2025 + section 1."""
if pymysql is None:
raise RuntimeError("Install pymysql: pip install pymysql")
port = env.get("DB_PORT")
if not port:
raise RuntimeError("DB_PORT missing in .env")
conn = pymysql.connect(
host=env.get("DB_HOST", "localhost"),
port=int(port),
user=env.get("DB_USERNAME", "root"),
password=env.get("DB_PASSWORD", ""),
database=env.get("DB_DATABASE", "eduverse_db"),
cursorclass=pymysql.cursors.DictCursor,
)
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT cs.section_id AS id
FROM course_sections cs
INNER JOIN courses c ON c.course_id = cs.course_id
INNER JOIN semesters s ON s.semester_id = cs.semester_id
WHERE c.course_code = %s AND s.semester_name = %s AND cs.section_number = %s
LIMIT 2
""",
(course_code.strip(), semester_name.strip(), str(section_number).strip()),
)
rows = cur.fetchall()
finally:
conn.close()
if not rows:
raise RuntimeError(
f"No section found for course_code={course_code!r} semester_name={semester_name!r} section_number={section_number!r}"
)
if len(rows) > 1:
raise RuntimeError("Multiple sections matched; narrow course/semester/section.")
return int(rows[0]["id"])
def http_get_json_auth(url: str, token: str | None, timeout: float = 60.0) -> object:
headers = {
"User-Agent": "EduVerse-fetch-section-faces/1.0",
"Accept": "application/json",
}
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def fetch_user_ids_from_api(base_url: str, section_id: int, token: str) -> list[int]:
"""GET {base}/api/enrollments/section/{id}/students — needs Instructor/TA/Admin JWT."""
base = base_url.rstrip("/")
url = f"{base}/api/enrollments/section/{section_id}/students"
data = http_get_json_auth(url, token)
if not isinstance(data, list):
raise RuntimeError(f"Unexpected API response (expected list): {type(data)}")
seen: set[int] = set()
out: list[int] = []
for row in data:
if not isinstance(row, dict):
continue
uid = row.get("userId") or row.get("user_id")
if uid is None:
continue
i = int(uid)
if i not in seen:
seen.add(i)
out.append(i)
out.sort()
return out
def http_get_json(url: str, timeout: float = 30.0) -> dict:
req = urllib.request.Request(url, headers={"User-Agent": "EduVerse-fetch-section-faces/1.0"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def _sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def _collect_other_hashes(dataset_dir: Path, skip_user_id: int | None) -> set[str]:
"""SHA256 of each numeric *.jpg except skip_user_id (so we can replace that file)."""
out: set[str] = set()
for p in dataset_dir.glob("*.jpg"):
if not p.stem.isdigit():
continue
uid = int(p.stem)
if skip_user_id is not None and uid == skip_user_id:
continue
out.add(_sha256_file(p))
return out
def audit_dataset(dataset_dir: Path) -> tuple[int, int, list[tuple[str, str, str]]]:
"""
Scan numeric *.jpg in dataset_dir.
Returns (total_files, unique_image_count, list of duplicate pairs (hash, path_a, path_b)).
"""
dataset_dir = dataset_dir.resolve()
hashes: dict[str, Path] = {}
dupes: list[tuple[str, str, str]] = []
total = 0
for p in sorted(dataset_dir.glob("*.jpg")):
if not p.stem.isdigit():
continue
total += 1
h = _sha256_file(p)
if h in hashes:
dupes.append((h, str(hashes[h]), str(p)))
else:
hashes[h] = p
return total, len(hashes), dupes
def download_portrait_for_user(
user_id: int,
dest: Path,
*,
dataset_dir: Path | None = None,
avoid_duplicate_of_others: bool = False,
max_attempts: int = 30,
) -> None:
"""Fetch one random portrait; map 1:1 to user_id for stable filenames.
If avoid_duplicate_of_others is True, retry until the JPEG bytes differ from every
other numeric *.jpg in dataset_dir (excluding this user_id). Helps when the API
returns the same portrait twice.
"""
dest.parent.mkdir(parents=True, exist_ok=True)
forbidden: set[str] = set()
if avoid_duplicate_of_others and dataset_dir is not None:
forbidden = _collect_other_hashes(dataset_dir, user_id)
for attempt in range(max_attempts):
url = "https://randomuser.me/api/?results=1&inc=picture"
data = http_get_json(url)
pic_url = data["results"][0]["picture"]["large"]
req = urllib.request.Request(pic_url, headers={"User-Agent": "EduVerse-fetch-section-faces/1.0"})
raw = urllib.request.urlopen(req, timeout=30).read()
tmp = dest.with_suffix(dest.suffix + ".part")
tmp.write_bytes(raw)
im = Image.open(tmp).convert("RGB")
im.save(dest, format="JPEG", quality=92)
tmp.unlink(missing_ok=True)
if not avoid_duplicate_of_others or not forbidden:
return
fp = _sha256_file(dest)
if fp not in forbidden:
return
print(
f" user_id={user_id}: duplicate of another portrait (attempt {attempt + 1}/{max_attempts}), retrying...",
file=sys.stderr,
)
raise RuntimeError(
f"Could not fetch a unique portrait for user_id={user_id} after {max_attempts} attempts"
)
def build_grid_composite(image_paths: list[Path], out_path: Path, cols: int = 6, cell_w: int = 200) -> None:
if not image_paths:
return
images: list[Image.Image] = []
for p in image_paths:
im = Image.open(p).convert("RGB")
h = int(cell_w * im.height / im.width)
images.append(im.resize((cell_w, h), Image.Resampling.LANCZOS))
rows = (len(images) + cols - 1) // cols
row_heights: list[int] = []
for r in range(rows):
chunk = images[r * cols : (r + 1) * cols]
row_heights.append(max(im.height for im in chunk))
total_h = sum(row_heights)
canvas = Image.new("RGB", (cols * cell_w, total_h), (32, 32, 32))
y = 0
for r in range(rows):
chunk = images[r * cols : (r + 1) * cols]
x = 0
for im in chunk:
y_off = y + (row_heights[r] - im.height) // 2
canvas.paste(im, (x, y_off))
x += cell_w
y += row_heights[r]
out_path.parent.mkdir(parents=True, exist_ok=True)
canvas.save(out_path, format="JPEG", quality=90)
def main() -> None:
parser = argparse.ArgumentParser(description="Download dataset face images for a section + composite.")
parser.add_argument("--section-id", type=int, default=26, help="course_sections.section_id")
parser.add_argument(
"--course-code",
type=str,
default="",
help='With --semester-name and --section-number, resolve section id from DB (e.g. AH102)',
)
parser.add_argument(
"--semester-name",
type=str,
default="",
help='Semester display name as in DB (e.g. "Fall 2025")',
)
parser.add_argument(
"--section-number",
type=str,
default="",
help='Section number string (e.g. "1")',
)
parser.add_argument(
"--api-url",
type=str,
default="",
help="Nest base URL (e.g. http://localhost:3001). With --bearer-token, roster from GET /api/enrollments/section/:id/students",
)
parser.add_argument(
"--bearer-token",
type=str,
default="",
help="JWT for Instructor/TA/Admin (used with --api-url)",
)
parser.add_argument(
"--user-ids",
type=str,
default="",
help="Comma-separated user IDs (highest priority; skips API/DB roster)",
)
parser.add_argument(
"--env",
type=Path,
default=Path(__file__).resolve().parents[2] / ".env",
help="Path to Nest .env for DB_* (default: EduVerse-Backend/.env)",
)
parser.add_argument(
"--dataset-dir",
type=Path,
default=Path(__file__).resolve().parent / "dataset",
help="Output folder for {userId}.jpg",
)
parser.add_argument(
"--composite",
type=Path,
default=None,
help="Path for tiled group JPEG (default: dataset/section_{id}_group.jpg)",
)
parser.add_argument("--cols", type=int, default=6, help="Grid columns for composite")
parser.add_argument(
"--fallback-user-ids",
type=str,
default="57",
help="If roster is empty, use these comma-separated IDs",
)
parser.add_argument(
"--refresh-user-ids",
type=str,
default="",
help="Comma-separated IDs to re-download only (e.g. 13 after a duplicate). Composite uses full roster.",
)
parser.add_argument(
"--rebuild-composite-only",
action="store_true",
help="Do not download; rebuild section_N_group.jpg from existing dataset files in roster order.",
)
parser.add_argument(
"--audit-only",
action="store_true",
help="Only scan dataset/*.jpg for counts and duplicate images; no download.",
)
args = parser.parse_args()
env = load_env_file(args.env)
dataset_dir = args.dataset_dir.resolve()
section_id = args.section_id
if args.course_code.strip() and args.semester_name.strip() and str(args.section_number).strip():
try:
section_id = resolve_section_id_from_db(
args.course_code,
args.semester_name,
str(args.section_number),
env,
)
print(f"Resolved section_id={section_id} ({args.course_code} / {args.semester_name} / sec {args.section_number})", file=sys.stderr)
except Exception as e:
print(f"Could not resolve section from DB: {e}", file=sys.stderr)
sys.exit(1)
if args.audit_only:
if not dataset_dir.is_dir():
print(f"Dataset dir missing: {dataset_dir}", file=sys.stderr)
sys.exit(1)
total, unique, dupes = audit_dataset(dataset_dir)
print(f"Dataset: {dataset_dir}")
print(f" Numeric .jpg files: {total}")
print(f" Unique images (by file hash): {unique}")
if dupes:
print(f" Duplicate image bytes ({len(dupes)} extra file(s)):", file=sys.stderr)
for _h, a, b in dupes:
print(f" same bytes: {a} <-> {b}", file=sys.stderr)
else:
print(" No duplicate image hashes across different student files.")
sys.exit(0)
if args.user_ids.strip():
user_ids = [int(x.strip()) for x in args.user_ids.split(",") if x.strip()]
elif args.api_url.strip() and args.bearer_token.strip():
try:
user_ids = fetch_user_ids_from_api(args.api_url.strip(), section_id, args.bearer_token.strip())
except Exception as e:
print(f"API roster failed ({e}); try DB or --user-ids.", file=sys.stderr)
sys.exit(1)
if not user_ids:
print("API returned no students.", file=sys.stderr)
sys.exit(1)
else:
user_ids = []
try:
user_ids = fetch_user_ids_from_db(section_id, env)
except Exception as e:
print(f"DB query failed ({e}); use --api-url + --bearer-token, --user-ids, or fix .env / pymysql.", file=sys.stderr)
user_ids = []
if not user_ids:
user_ids = [int(x.strip()) for x in args.fallback_user_ids.split(",") if x.strip()]
print(f"Using fallback user IDs: {user_ids}", file=sys.stderr)
if not user_ids:
print("No user IDs to process.", file=sys.stderr)
sys.exit(1)
refresh_set: set[int] = set()
if args.refresh_user_ids.strip():
refresh_set = {int(x.strip()) for x in args.refresh_user_ids.split(",") if x.strip()}
saved: list[Path] = []
if args.rebuild_composite_only:
for uid in user_ids:
p = dataset_dir / f"{uid}.jpg"
if not p.is_file():
print(f"Missing {p}; cannot build composite.", file=sys.stderr)
sys.exit(1)
saved.append(p)
else:
targets = refresh_set if refresh_set else set(user_ids)
if refresh_set:
missing = refresh_set - set(user_ids)
if missing:
print(f"--refresh-user-ids contains IDs not in roster: {missing}", file=sys.stderr)
sys.exit(1)
for uid in sorted(targets):
dest = dataset_dir / f"{uid}.jpg"
print(f"Downloading portrait for user_id={uid} -> {dest}")
try:
download_portrait_for_user(
uid,
dest,
dataset_dir=dataset_dir,
avoid_duplicate_of_others=True,
)
except (urllib.error.URLError, OSError, KeyError, IndexError, RuntimeError) as e:
print(f" Failed: {e}", file=sys.stderr)
for uid in user_ids:
p = dataset_dir / f"{uid}.jpg"
if not p.is_file():
print(f"Missing reference image {p}; run without --refresh-user-ids to fetch all.", file=sys.stderr)
sys.exit(1)
saved.append(p)
if not saved:
print("No images for composite.", file=sys.stderr)
sys.exit(1)
comp = args.composite
if comp is None:
comp = dataset_dir / f"section_{section_id}_group.jpg"
else:
comp = comp.resolve()
print(f"Building composite -> {comp}")
build_grid_composite(saved, comp, cols=args.cols)
total, unique, dupes = audit_dataset(dataset_dir)
print(
f"Dataset check: {total} numeric .jpg file(s), {unique} unique image hash(es).",
file=sys.stderr,
)
if dupes:
print("Warning: duplicate image bytes between files:", file=sys.stderr)
for _h, a, b in dupes:
print(f" {a} <-> {b}", file=sys.stderr)
print("Done.")
if __name__ == "__main__":
main()