Spaces:
Sleeping
Sleeping
| """ | |
| 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() | |