diff --git a/docs/engineering/unified_conversations_and_participant_profiles.md b/docs/engineering/unified_conversations_and_participant_profiles.md index c18be6aba..09d9d3908 100644 --- a/docs/engineering/unified_conversations_and_participant_profiles.md +++ b/docs/engineering/unified_conversations_and_participant_profiles.md @@ -91,8 +91,28 @@ Avatar variants use Von's existing scope modes: Canonical file-copy visibility enforces the audience. Matching combined, user, organisation and global variants take precedence in that order; a variant from a different organisation is not selected as the current organisation's portrait. -Publication creates a centred 256-pixel PNG derivative with source metadata -removed. The original upload/generated candidate stays private. Replacing or +Publication creates a 256-pixel PNG derivative with source metadata removed. +The editor accepts a file picker or one dropped PNG, JPEG or WebP (up to 8 MiB), +preserves the source through the private image store, and offers zoom and position +controls. `/avatar/prepare` applies EXIF orientation before suggesting framing. +The bundled OpenCV frontal-face detector runs locally: one detected face gets a +suggested crop, while zero or multiple faces retain a centred crop for manual +correction. Detector failure also preserves manual upload; locations do not +identify people or enrol biometric identities. Detection can miss faces and is +only an adjustable default. + +`/avatar` accepts an optional square `crop` (`x`, `y`, `size`) in oriented source +pixels. `/avatar/generate` accepts a private `source_image_concept_id`; after +checking its owner, it sends oriented, metadata-free photo pixels to the existing +configured OpenAI client using image edits with `gpt-image-1.5`. The style is the +user's prompt. Without a source it retains text-only generation. These are +distinct provider operations; image-edit failure does not fall back to text-only +generation. Generated candidates record their private source provenance and +require explicit **Use avatar**. **Return to source photo** restores manual +framing, including after provider failure. Existing MCP calls retain their +previous text-generation and centred-publication interface. + +The original upload/generated candidate stays private. Replacing or removing a variant retires that publication, allowing another applicable variant to become visible. Image reads validate the viewer and window context, use private no-store caching, and never expose the private source blob URL in the public diff --git a/pdm.lock b/pdm.lock index 1f4b3d008..f03ed0924 100644 --- a/pdm.lock +++ b/pdm.lock @@ -14,7 +14,7 @@ requires-python = ">=3.11" sources = [] [metadata.lock_inputs.project.groups] -default = ["Jinja2", "MarkupSafe", "PyYAML", "Pygments", "SPARQLWrapper", "Werkzeug", "Whoosh", "annotated-types", "anyio", "asttokens", "beautifulsoup4", "beautifulsoup4", "bibtexparser", "black", "bleach", "blinker", "boto3", "cachetools", "certifi", "charset-normalizer", "click", "colorama", "comm", "cryptography", "customtkinter", "darkdetect", "datetime", "debugpy", "decorator", "distro", "docutils", "exceptiongroup", "executing", "extract-msg", "fastapi", "feedparser", "flask", "flask-testing", "gcloud", "google-api-core", "google-api-python-client", "google-auth", "google-auth-httplib2", "google-auth-oauthlib", "google-genai", "googleapis-common-protos", "h11", "httpcore", "httplib2", "httpx", "idna", "ipykernel", "ipython", "itsdangerous", "jedi", "jupyter-client", "jupyter-core", "latexcodec", "llama-index", "markdown", "matplotlib-inline", "mcp", "mypy-extensions", "nest-asyncio", "node", "npm", "numpy", "oauth2client", "oauthlib", "odfpy", "odict", "ollama", "openai", "openpyxl", "openstacksdk", "optional-django", "packaging", "pandas", "parso", "pathspec", "pdfminer-six", "pillow", "platformdirs", "plumber", "prompt-toolkit", "proto-plus", "psutil", "pure-eval", "pyasn1", "pyasn1-modules", "pybtex", "pybtex-apa7-style", "pybtex-docutils", "pydantic", "pydantic-core", "pymongo", "pymupdf", "pyparsing", "pysimplegui", "pytesseract", "python-dateutil", "python-docx", "python-dotenv", "python-pptx", "pytz", "pywin32; platform_system == \"Windows\"", "pyzmq", "rdflib", "requests", "requests-oauthlib", "rsa", "setuptools", "six", "slack-bolt", "sniffio", "stack-data", "striprtf", "tavily-python", "tokenize-rt", "tornado", "tqdm", "traitlets", "typing-extensions", "tzdata", "uritemplate", "urllib3", "uv", "uvicorn", "waitress", "wcwidth", "zope-component", "zope-deferredimport", "zope-deprecation", "zope-event", "zope-hookable", "zope-interface", "zope-lifecycleevent", "zope-proxy"] +default = ["Jinja2", "MarkupSafe", "PyYAML", "Pygments", "SPARQLWrapper", "Werkzeug", "Whoosh", "annotated-types", "anyio", "asttokens", "beautifulsoup4", "beautifulsoup4", "bibtexparser", "black", "bleach", "blinker", "boto3", "cachetools", "certifi", "charset-normalizer", "click", "colorama", "comm", "cryptography", "customtkinter", "darkdetect", "datetime", "debugpy", "decorator", "distro", "docutils", "exceptiongroup", "executing", "extract-msg", "fastapi", "feedparser", "flask", "flask-testing", "gcloud", "google-api-core", "google-api-python-client", "google-auth", "google-auth-httplib2", "google-auth-oauthlib", "google-genai", "googleapis-common-protos", "h11", "httpcore", "httplib2", "httpx", "idna", "ipykernel", "ipython", "itsdangerous", "jedi", "jupyter-client", "jupyter-core", "latexcodec", "llama-index", "markdown", "matplotlib-inline", "mcp", "mypy-extensions", "nest-asyncio", "node", "npm", "numpy", "oauth2client", "oauthlib", "odfpy", "odict", "ollama", "openai", "opencv-python-headless", "openpyxl", "openstacksdk", "optional-django", "packaging", "pandas", "parso", "pathspec", "pdfminer-six", "pillow", "platformdirs", "plumber", "prompt-toolkit", "proto-plus", "psutil", "pure-eval", "pyasn1", "pyasn1-modules", "pybtex", "pybtex-apa7-style", "pybtex-docutils", "pydantic", "pydantic-core", "pymongo", "pymupdf", "pyparsing", "pysimplegui", "pytesseract", "python-dateutil", "python-docx", "python-dotenv", "python-pptx", "pytz", "pywin32; platform_system == \"Windows\"", "pyzmq", "rdflib", "requests", "requests-oauthlib", "rsa", "setuptools", "six", "slack-bolt", "sniffio", "stack-data", "striprtf", "tavily-python", "tokenize-rt", "tornado", "tqdm", "traitlets", "typing-extensions", "tzdata", "uritemplate", "urllib3", "uv", "uvicorn", "waitress", "wcwidth", "zope-component", "zope-deferredimport", "zope-deprecation", "zope-event", "zope-hookable", "zope-interface", "zope-lifecycleevent", "zope-proxy"] dev = ["annotated-types", "anyio", "blinker", "mongomock", "pymongo", "pyright", "pytest", "pytest-asyncio", "pytest-cov", "pytest-mock", "ruff"] geo = ["geopandas", "pyogrio", "pyproj", "shapely"] @@ -3011,6 +3011,28 @@ files = [ {file = "openai-2.54.0.tar.gz", hash = "sha256:e3e6f8bc1ba30ddf381ace1a14340eed381cb984a1a59bd0f34b5be3b5d49cfa"}, ] +[[package]] +name = "opencv-python-headless" +version = "4.14.0.94" +requires_python = ">=3.6" +summary = "Wrapper package for OpenCV python bindings." +groups = ["default"] +dependencies = [ + "numpy<2.0; python_version < \"3.9\"", + "numpy>=2; python_version >= \"3.9\"", +] +files = [ + {file = "opencv_python_headless-4.14.0.94-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:bc7db37dc234f7bb3190a158fd9dd750357246fc7d8adb23698843ef721a993b"}, + {file = "opencv_python_headless-4.14.0.94-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:1777f43c9fa064f54b916ad70d944b4fde0a644a17e49f04a966bb24a4b5f1e1"}, + {file = "opencv_python_headless-4.14.0.94-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:29714d7716dbfddf9fec20ffb878765e94ccaecd7d3ebd6750877420296e2dc5"}, + {file = "opencv_python_headless-4.14.0.94-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5e02669eac0ba67b2a22d7245af1e8ee1a2ef1185ee526a063d8f0555224bd52"}, + {file = "opencv_python_headless-4.14.0.94-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:97c6e818c6f71c0cfa214e12293b1d0266d679c38f4e086de08357c8ac6ece0d"}, + {file = "opencv_python_headless-4.14.0.94-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:211e581f5a4670acbbe08fff36a35e9946039d2eea28b80394632d036d1be527"}, + {file = "opencv_python_headless-4.14.0.94-cp37-abi3-win32.whl", hash = "sha256:f70296aa7ac9d7ade0d925c43fdc006c83e20a23f30e2f40f1b82c74a7a54460"}, + {file = "opencv_python_headless-4.14.0.94-cp37-abi3-win_amd64.whl", hash = "sha256:cbed65415b8f6a9541c705afe3e64795840524d0ff3bc58f507826284a1dc64b"}, + {file = "opencv_python_headless-4.14.0.94.tar.gz", hash = "sha256:4afa2ea1214453648be88259f035712454faa9039b686de7753569ba8eec1577"}, +] + [[package]] name = "openpyxl" version = "3.1.5" diff --git a/pyproject.toml b/pyproject.toml index 3ce0d5100..021e9e5fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,7 @@ dependencies = [ "zope.proxy==5.2", "customtkinter>=5.2.2", "pillow>=8.0.0", + "opencv-python-headless>=4.10,<5", "annotated-types>=0.7.0", "asttokens>=3.0.0", "bibtexparser>=1.4.3", diff --git a/src/backend/server/routes/participant_profile_routes.py b/src/backend/server/routes/participant_profile_routes.py index b08e8e9e4..e4fc69718 100644 --- a/src/backend/server/routes/participant_profile_routes.py +++ b/src/backend/server/routes/participant_profile_routes.py @@ -1,6 +1,7 @@ """Common profile API for Settings, concepts, conversations and agent tools.""" import io +import json import logging from flask import jsonify, request, send_file, session @@ -19,12 +20,16 @@ def perform(action): return jsonify(error="profile_unavailable", message=str(exc)), 403 except ValueError as exc: return jsonify(error="invalid_profile_image", message=str(exc)), 400 - except Exception: # noqa: BLE001 - provider/storage failures are reported at the HTTP boundary + # Provider/storage failures are reported at the HTTP boundary. + except Exception: # noqa: BLE001 _log.exception("Participant profile operation failed") - return jsonify( - error="profile_operation_failed", - message="The profile operation failed. Your existing avatar is unchanged unless saving completed; refresh to check.", - ), 503 + return ( + jsonify( + error="profile_operation_failed", + message="The profile operation failed. Your existing avatar is unchanged unless saving completed; refresh to check.", + ), + 503, + ) @blueprint.route("/api/participants/profile", methods=["GET"]) def participant_profile(): @@ -54,10 +59,29 @@ def participant_avatar_save(): remove=payload.get("remove") is True, scope=payload.get("scope", "global_general"), data=uploaded.read(MAX_IMAGE_BYTES + 1) if uploaded else None, + crop=( + json.loads(payload["crop"]) + if isinstance(payload.get("crop"), str) + else payload.get("crop") + ), ) } ) + @blueprint.route("/api/participants/avatar/prepare", methods=["POST"]) + def participant_avatar_prepare(): + def prepare(): + uploaded = request.files.get("file") + if uploaded is None: + raise ValueError("Choose an image first.") + return profiles.prepare_avatar( + data=uploaded.read(MAX_IMAGE_BYTES + 1), + filename=uploaded.filename or "avatar-source.png", + concept_id=request.form.get("concept_id"), + ) + + return perform(prepare) + @blueprint.route("/api/participants/avatar/generate", methods=["POST"]) def participant_avatar_generate(): payload = request.get_json(silent=True) or {} @@ -66,6 +90,7 @@ def participant_avatar_generate(): "image": profiles.generate_avatar( concept_id=payload.get("concept_id"), prompt=payload.get("prompt"), + source_image_concept_id=payload.get("source_image_concept_id"), ) } ) diff --git a/src/backend/services/avatar_framing.py b/src/backend/services/avatar_framing.py new file mode 100644 index 000000000..43a891f37 --- /dev/null +++ b/src/backend/services/avatar_framing.py @@ -0,0 +1,29 @@ +"""Local face locations for an adjustable avatar crop; no identity inference.""" + +from PIL import Image + + +def find_faces(image: Image.Image) -> list[dict]: + import cv2 + import numpy as np + + # OpenCV ships this detector. No remote model or photo transfer is needed. + detector = cv2.CascadeClassifier( + cv2.data.haarcascades + "haarcascade_frontalface_default.xml" + ) + thumbnail = image.copy() + thumbnail.thumbnail((1024, 1024)) + grey = cv2.cvtColor(np.asarray(thumbnail), cv2.COLOR_RGB2GRAY) + boxes = detector.detectMultiScale( + grey, scaleFactor=1.1, minNeighbors=5, minSize=(24, 24) + ) + sx, sy = image.width / thumbnail.width, image.height / thumbnail.height + return [ + { + "x": float(x * sx), + "y": float(y * sy), + "width": float(w * sx), + "height": float(h * sy), + } + for x, y, w, h in boxes + ] diff --git a/src/backend/services/participant_profile_service.py b/src/backend/services/participant_profile_service.py index d2ca76b8c..661d4f7db 100644 --- a/src/backend/services/participant_profile_service.py +++ b/src/backend/services/participant_profile_service.py @@ -9,6 +9,8 @@ import base64 import hashlib import io +import logging +import math from urllib.parse import quote from PIL import Image, ImageOps @@ -137,13 +139,17 @@ def get_profiles(concept_ids: list[str]) -> list[dict]: or cid.removeprefix("#V#").replace("_", " "), "can_edit": actor == cid and _can_edit_profile(actor), "avatar_scope": avatar.get("avatar_scope"), - "avatar_url": f"/von/api/participants/{quote(cid, safe='')}/avatar?v={avatar['sha256']}" - if avatar.get("sha256") - else None, + "avatar_url": ( + f"/von/api/participants/{quote(cid, safe='')}/avatar?v={avatar['sha256']}" + if avatar.get("sha256") + else None + ), "avatar_version": avatar.get("sha256"), - "available_scopes": list(SCOPES) - if get_effective_organisation_concept_id() - else ["user_only_default", "global_general"], + "available_scopes": ( + list(SCOPES) + if get_effective_organisation_concept_id() + else ["user_only_default", "global_general"] + ), } ) return result @@ -164,6 +170,7 @@ def set_avatar( data: bytes | None = None, remove: bool = False, scope: str = "global_general", + crop: dict | None = None, ) -> dict: actor, concept_id, _ = _participant(concept_id, edit=True) from .concept_service import update_concept @@ -193,6 +200,8 @@ def set_avatar( inspect_image(data) with Image.open(io.BytesIO(data)) as original: oriented = ImageOps.exif_transpose(original) + if crop is not None: + oriented = oriented.crop(_crop_box(crop, *oriented.size)) # Reconstruct pixels so EXIF, comments and private source metadata cannot # be carried into the published derivative. resized = ImageOps.fit( @@ -254,25 +263,91 @@ def load_avatar(concept_id: str) -> bytes: return data -def generate_avatar(*, prompt: str, concept_id: str | None = None) -> dict: +def _crop_box(crop: dict, width: int, height: int) -> tuple: + if not isinstance(crop, dict) or set(crop) != {"x", "y", "size"}: + raise ValueError("Supply a square crop with x, y and size.") + x, y, size = (crop[k] for k in ("x", "y", "size")) + if any(type(v) not in (int, float) or not math.isfinite(v) for v in (x, y, size)): + raise ValueError("Crop coordinates must be finite numbers.") + if x < 0 or y < 0 or size < 1 or x + size > width or y + size > height: + raise ValueError("Crop must lie within the oriented source image.") + return (x, y, x + size, y + size) + + +def prepare_avatar( + *, data: bytes, filename: str, concept_id: str | None = None +) -> dict: + """Preserve the private original and suggest framing without identifying anyone.""" + actor, _, _ = _participant(concept_id, edit=True) + inspect_image(data) + with Image.open(io.BytesIO(data)) as original: + oriented = ImageOps.exif_transpose(original).convert("RGB") + width, height = oriented.size + from .avatar_framing import find_faces + + detection_available = True + try: + faces = find_faces(oriented) + # A detector failure must not prevent ordinary uploads and manual cropping. + except Exception: # noqa: BLE001 + logging.getLogger(__name__).warning("Avatar face detection unavailable") + detection_available, faces = False, [] + size = min(width, height) + x, y = (width - size) / 2, (height - size) / 2 + if len(faces) == 1: + face = faces[0] + size = min(size, max(face["width"], face["height"]) * 2) + x = min(width - size, max(0, face["x"] + face["width"] / 2 - size / 2)) + y = min(height - size, max(0, face["y"] + face["height"] / 2 - size * 0.45)) + image = store_image(data=data, filename=filename, user_concept_id=actor) + return { + "image": image, + "width": width, + "height": height, + "face_count": len(faces), + "face_detection_available": detection_available, + "crop": {"x": x, "y": y, "size": size}, + } + + +def generate_avatar( + *, + prompt: str, + concept_id: str | None = None, + source_image_concept_id: str | None = None, +) -> dict: """Generate a private candidate; publishing is a separate, reusable action.""" actor, _, _ = _participant(concept_id, edit=True) if not isinstance(prompt, str) or not prompt.strip() or len(prompt) > 4000: raise ValueError("Describe the image in 1–4000 characters.") from ..languagemodels.openai_client import OpenAIClient + source = None + if source_image_concept_id: + source, original_data = load_image(source_image_concept_id, actor) + # Send oriented pixels, without private EXIF, to the configured provider. + with Image.open(io.BytesIO(original_data)) as original: + oriented = ImageOps.exif_transpose(original).convert("RGB") + clean = Image.new("RGB", oriented.size) + clean.paste(oriented) + image_input = io.BytesIO() + clean.save(image_input, format="PNG") + image_input.name = "avatar-source.png" + image_input.seek(0) # Bounded single image; no automatic retry that could duplicate provider cost. + images = OpenAIClient().client.with_options(max_retries=0).images + options = { + "model": "gpt-image-1.5", + "prompt": prompt.strip(), + "n": 1, + "size": "1024x1024", + "quality": "low", + "output_format": "png", + } result = ( - OpenAIClient() - .client.with_options(max_retries=0) - .images.generate( - model="gpt-image-1.5", - prompt=prompt.strip(), - n=1, - size="1024x1024", - quality="low", - output_format="png", - ) + images.edit(image=image_input, **options) + if source + else images.generate(**options) ) if not result.data or not result.data[0].b64_json: raise ValueError("The image provider returned no image.") @@ -285,5 +360,13 @@ def generate_avatar(*, prompt: str, concept_id: str | None = None) -> dict: "kind": "generated", "model": "gpt-image-1.5", "prompt": prompt.strip(), + **( + { + "parent_concept_id": source_image_concept_id, + "parent_sha256": source["sha256"], + } + if source + else {} + ), }, ) diff --git a/src/frontend/web/von_interface/static/css/participantProfile.css b/src/frontend/web/von_interface/static/css/participantProfile.css index cbb255c11..d7c76e788 100644 --- a/src/frontend/web/von_interface/static/css/participantProfile.css +++ b/src/frontend/web/von_interface/static/css/participantProfile.css @@ -7,6 +7,15 @@ .participant-profile-dialog textarea { display: block; box-sizing: border-box; width: 100%; min-height: 80px; margin-top: 12px; padding: 8px; } .participant-avatar-preview { display: block; width: 160px; height: 160px; object-fit: cover; border-radius: 50%; margin: 12px auto; } .participant-avatar-preview[hidden] { display: none; } +.participant-avatar-drop { display: block; border: 2px dashed #aebdca; border-radius: 8px; padding: 12px; margin-top: 12px; } +.participant-avatar-drop input { display: block; max-width: 100%; margin-top: 8px; } +.participant-avatar-crop { min-width: 0; margin: 12px 0; } +.participant-avatar-crop label { display: block; } +.participant-avatar-crop input { display: block; width: 100%; margin: 8px 0; } +.participant-profile-dialog [hidden] { display: none; } +.participant-profile-dialog { box-sizing: border-box; } +.participant-profile-dialog select { max-width: 100%; } +@media (max-width: 480px) { .participant-profile-dialog { padding: 16px; width: calc(100vw - 24px); } } .participant-profile-dialog > div > .participant-avatar { width: 64px; height: 64px; vertical-align: middle; } .unified-message-content { display: none; min-width: 0; min-height: 0; flex: 1; } .show-message-exchange > .chat-conversation-main { display: none; } diff --git a/src/frontend/web/von_interface/static/js/components/participantProfile.js b/src/frontend/web/von_interface/static/js/components/participantProfile.js index c31650879..3ea28e69d 100644 --- a/src/frontend/web/von_interface/static/js/components/participantProfile.js +++ b/src/frontend/web/von_interface/static/js/components/participantProfile.js @@ -97,7 +97,7 @@ export async function openParticipantProfile(conceptId = null) { status.textContent = ''; if (!profile.can_edit) return; const audience = document.createElement('p'); - audience.textContent = 'Generation makes one image request and may incur a provider charge. The preview stays private until you choose Use avatar. Choose who can see this avatar using its scope. More specific avatars take precedence in the matching context. Uploaded images are centred and cropped to a square.'; + audience.textContent = 'Your source photo and previews stay private. Use avatar publishes only the cropped avatar to the selected scope.'; const scope = document.createElement('select'); scope.setAttribute('aria-label', 'Avatar scope'); const scopeNames = { user_org_default: 'User and organisation', user_only_default: 'User only', organisation_general: 'Organisation', global_general: 'Global' }; @@ -108,43 +108,123 @@ export async function openParticipantProfile(conceptId = null) { input.type = 'file'; input.accept = 'image/png,image/jpeg,image/webp'; input.setAttribute('aria-label', 'Upload avatar image'); + const dropZone = document.createElement('label'); + dropZone.className = 'participant-avatar-drop'; + dropZone.textContent = 'Drop a PNG, JPEG or WebP photo here, or choose a file (up to 8 MiB).'; + dropZone.append(input); const preview = document.createElement('img'); preview.className = 'participant-avatar-preview'; preview.alt = 'Avatar preview'; preview.hidden = true; const prompt = document.createElement('textarea'); - prompt.placeholder = 'Describe an avatar to generate'; + prompt.placeholder = 'Describe the desired avatar or photo style, e.g. a watercolour portrait'; prompt.setAttribute('aria-label', 'Avatar image description'); prompt.maxLength = 4000; + const generationNote = document.createElement('p'); + generationNote.textContent = 'Generation sends your source photo and style description to the configured OpenAI image provider. One image request may incur a charge. Preview the result before choosing Use avatar.'; let candidate = null; - let objectUrl = null; + let source = null; let pending = false; + const cropControls = document.createElement('fieldset'); + cropControls.className = 'participant-avatar-crop'; + cropControls.hidden = true; + const legend = document.createElement('legend'); + legend.textContent = 'Adjust framing'; + cropControls.append(legend); + const sliders = {}; + for (const [key, name, min, max, value] of [ + ['zoom', 'Zoom', 1, 8, 1], ['x', 'Horizontal position', 0, 100, 50], ['y', 'Vertical position', 0, 100, 50] + ]) { + const label = document.createElement('label'); + label.textContent = name; + const slider = document.createElement('input'); + slider.type = 'range'; slider.min = min; slider.max = max; slider.step = '0.01'; slider.value = value; + slider.setAttribute('aria-label', name); + slider.addEventListener('input', () => renderCrop()); + sliders[key] = slider; + label.append(slider); cropControls.append(label); + } + const renderCrop = () => { + if (!source) return; + const { width, height, image, bitmap } = source; + const size = Math.min(width, height) / Number(sliders.zoom.value); + const x = (width - size) * Number(sliders.x.value) / 100; + const y = (height - size) * Number(sliders.y.value) / 100; + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 256; + canvas.getContext('2d').drawImage(bitmap, x, y, size, size, 0, 0, 256, 256); + preview.src = canvas.toDataURL('image/png'); + preview.hidden = false; + candidate = { image_concept_id: image.concept_id, crop: { x, y, size } }; + cropControls.hidden = false; + use.disabled = pending; + }; + const restoreSource = () => { + if (!source) return; + const { width, height, crop } = source; + sliders.zoom.value = Math.min(width, height) / crop.size; + const size = Math.min(width, height) / Number(sliders.zoom.value); + sliders.x.value = width === size ? 50 : crop.x / (width - size) * 100; + sliders.y.value = height === size ? 50 : crop.y / (height - size) * 100; + renderCrop(); + }; + const restore = button('Return to source photo', restoreSource); + restore.hidden = true; const run = async (message, action) => { if (pending) return; pending = true; status.textContent = message; body.querySelectorAll('button, input, textarea, select').forEach(el => { el.disabled = true; }); - try { await action(); } catch (error) { status.textContent = error.message || 'Could not save the avatar. Try again.'; } + try { await action(); } catch (error) { status.textContent = error.payload?.message || error.message || 'Could not save the avatar. Try again.'; } finally { pending = false; body.querySelectorAll('button, input, textarea, select').forEach(el => { el.disabled = false; }); use.disabled = !candidate; } }; - input.addEventListener('change', () => { - if (!input.files[0]) return; - if (objectUrl) URL.revokeObjectURL(objectUrl); - objectUrl = URL.createObjectURL(input.files[0]); - candidate = { file: input.files[0] }; - preview.src = objectUrl; - preview.hidden = false; - use.disabled = false; + const chooseFile = file => run('Preparing your photo…', async () => { + if (!file) return; + if (file.size > 8 * 1024 * 1024 || !/\.(png|jpe?g|webp)$/i.test(file.name)) { + throw new Error('Choose a still PNG, JPEG or WebP image up to 8 MiB.'); + } + const form = new FormData(); + form.append('file', file); + form.append('concept_id', profile.concept_id); + const response = await fetch('/von/api/participants/avatar/prepare', { + method: 'POST', headers: { 'X-Von-Window-Session': await ensureUniqueWindowSessionId() }, body: form + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.message || 'Could not prepare this photo.'); + const bitmap = new Image(); + bitmap.src = result.image.url; + await bitmap.decode(); + if (!dialog.isConnected) return; + source = { ...result, bitmap }; + restore.hidden = false; + generate.textContent = 'Generate from photo'; + restoreSource(); + status.textContent = result.face_detection_available === false ? 'Automatic face framing is unavailable. You can still adjust the crop and use your photo.' + : result.face_count === 1 ? 'Face framed. Adjust the crop if needed, then choose Use avatar.' + : result.face_count > 1 ? 'Multiple faces found. Adjust the crop to choose the person you want.' + : 'No face found. Adjust the crop to frame your photo.'; + }); + input.addEventListener('change', () => void chooseFile(input.files[0])); + for (const name of ['dragover', 'drop']) dropZone.addEventListener(name, event => { + event.preventDefault(); + if (name === 'drop' && !pending) { + if (event.dataTransfer.files.length !== 1) { status.textContent = 'Drop one image at a time.'; return; } + void chooseFile(event.dataTransfer.files[0]); + } }); const generate = button('Generate preview', () => run('Generating an image…', async () => { - const result = await postJson('/von/api/participants/avatar/generate', { concept_id: profile.concept_id, prompt: prompt.value }); + const result = await postJson('/von/api/participants/avatar/generate', { + concept_id: profile.concept_id, prompt: prompt.value, + ...(source ? { source_image_concept_id: source.image.concept_id } : {}) + }); candidate = { image_concept_id: result.image.concept_id }; preview.src = result.image.url; preview.hidden = false; + cropControls.hidden = true; status.textContent = 'Preview ready. Choose Use avatar to publish it.'; })); const saved = result => { @@ -158,24 +238,15 @@ export async function openParticipantProfile(conceptId = null) { status.textContent = 'Avatar saved.'; }; const use = button('Use avatar', () => run('Saving avatar…', async () => { - if (candidate.file) { - const form = new FormData(); - form.append('file', candidate.file); - form.append('concept_id', profile.concept_id); - form.append('scope', scope.value); - const response = await fetch('/von/api/participants/avatar', { method: 'POST', headers: { 'X-Von-Window-Session': await ensureUniqueWindowSessionId() }, body: form }); - const result = await response.json(); - if (!response.ok) throw new Error(result.message || 'Upload failed.'); - saved(result); - } else saved(await postJson('/von/api/participants/avatar', { concept_id: profile.concept_id, scope: scope.value, ...candidate })); + saved(await postJson('/von/api/participants/avatar', { concept_id: profile.concept_id, scope: scope.value, ...candidate })); })); use.disabled = true; const remove = button('Remove avatar', () => run('Removing avatar…', async () => { saved(await postJson('/von/api/participants/avatar', { concept_id: profile.concept_id, scope: scope.value, remove: true })); candidate = null; preview.hidden = true; + cropControls.hidden = true; })); - body.append(input, prompt, generate, preview, use, remove); - dialog.addEventListener('close', () => { if (objectUrl) URL.revokeObjectURL(objectUrl); }); + body.append(dropZone, preview, cropControls, restore, prompt, generationNote, generate, use, remove); } catch (error) { status.textContent = error.message || 'Profile unavailable.'; } } diff --git a/tests/backend/test_participant_profiles.py b/tests/backend/test_participant_profiles.py index 2dd29c6a1..814c5965c 100644 --- a/tests/backend/test_participant_profiles.py +++ b/tests/backend/test_participant_profiles.py @@ -75,7 +75,17 @@ def test_avatar_scope_precedence_and_context(monkeypatch, org, expected): assert profiles._resolve_avatar(records, "#V#alice")["concept_id"] == expected -def test_upload_publishes_only_sanitised_derivative_in_requested_scope(monkeypatch): +@pytest.mark.parametrize( + "crop,orientation,colour", + [ + (None, None, (255, 165, 0, 255)), + ({"x": 400, "y": 0, "size": 200}, None, (0, 0, 255, 255)), + ({"x": 0, "y": 400, "size": 200}, 6, (0, 0, 255, 255)), + ], +) +def test_upload_publishes_only_sanitised_derivative_in_requested_scope( + monkeypatch, crop, orientation, colour +): from src.backend.services import blob_uploads, computer_file_copy_service monkeypatch.setattr( @@ -111,14 +121,22 @@ def create(**kwargs): ) monkeypatch.setattr(profiles, "get_profile", lambda cid: {"concept_id": cid}) image = Image.new("RGB", (600, 300), "orange") + image.paste("blue", (400, 0, 600, 300)) metadata = PngImagePlugin.PngInfo() metadata.add_text("private-note", "private source information") source = io.BytesIO() - image.save(source, format="PNG", pnginfo=metadata) - result = profiles.set_avatar(data=source.getvalue(), scope="organisation_general") + exif = Image.Exif() + if orientation: + exif[274] = orientation + image.save(source, format="PNG", pnginfo=metadata, exif=exif) + result = profiles.set_avatar( + data=source.getvalue(), scope="organisation_general", crop=crop + ) derivative = Image.open(io.BytesIO(saved["data"])) assert derivative.size == (256, 256) assert "private-note" not in derivative.info + assert not derivative.getexif() + assert derivative.getpixel((128, 128)) == colour assert saved["file"]["visibility_scope_mode"] == "organisation_general" assert saved["file"]["metadata"]["avatar_subject_id"] == "#V#alice" assert result["saved_scope"] == "organisation_general" @@ -166,3 +184,294 @@ def test_read_avatar_requires_visible_published_record(monkeypatch): monkeypatch.setattr(profiles, "_avatar_records", lambda ids: []) with pytest.raises(PermissionError): profiles.load_avatar("#V#bob") + + +def test_crop_is_applied_after_orientation_and_original_is_preserved(monkeypatch): + from src.backend.services import avatar_framing + + source = io.BytesIO() + image = Image.new("RGB", (400, 200), "red") + exif = Image.Exif() + exif[274] = 6 + image.save(source, format="JPEG", exif=exif) + original = source.getvalue() + monkeypatch.setattr( + profiles, "_participant", lambda *a, **k: ("#V#alice", "#V#alice", {}) + ) + stored = [] + monkeypatch.setattr( + profiles, + "store_image", + lambda **kw: stored.append(kw) or {"concept_id": "#V#source"}, + ) + monkeypatch.setattr(avatar_framing, "find_faces", lambda img: []) + result = profiles.prepare_avatar(data=original, filename="portrait.jpg") + assert (result["width"], result["height"]) == (200, 400) + assert result["crop"] == {"x": 0, "y": 100, "size": 200} + assert stored[0]["data"] == original + assert stored[0]["user_concept_id"] == "#V#alice" + assert profiles._crop_box({"x": 0, "y": 200, "size": 200}, 200, 400) == ( + 0, + 200, + 200, + 400, + ) + + +@pytest.mark.parametrize( + "crop", + [ + None, + {}, + {"x": -1, "y": 0, "size": 10}, + {"x": 0, "y": 0, "size": 101}, + {"x": 0, "y": 0, "size": float("nan")}, + {"x": True, "y": 0, "size": 10}, + ], +) +def test_invalid_crop_is_rejected(crop): + with pytest.raises(ValueError): + profiles._crop_box(crop, 100, 100) + + +def test_photo_generation_sends_oriented_pixels_and_provenance_without_publication( + monkeypatch, +): + import base64 + + from src.backend.languagemodels import openai_client + + original = io.BytesIO() + exif = Image.Exif() + exif[274] = 6 + exif[270] = "private note" + Image.new("RGB", (120, 80), "blue").save(original, "JPEG", exif=exif) + calls = [] + + class Client: + def with_options(self, **options): + assert options == {"max_retries": 0} + return self + + @property + def images(self): + return self + + def edit(self, **kwargs): + image = Image.open(kwargs["image"]) + assert image.size == (80, 120) + assert not image.getexif() + calls.append(kwargs) + return SimpleNamespace( + data=[SimpleNamespace(b64_json=base64.b64encode(b"preview").decode())] + ) + + def generate(self, **kwargs): + pytest.fail("Photo-conditioned generation must never become text-only") + + monkeypatch.setattr( + profiles, "_participant", lambda *a, **k: ("#V#alice", "#V#alice", {}) + ) + + def load(cid, actor): + assert (cid, actor) == ("#V#source", "#V#alice") + return {"sha256": "source-hash"}, original.getvalue() + + monkeypatch.setattr(profiles, "load_image", load) + monkeypatch.setattr( + openai_client, "OpenAIClient", lambda: SimpleNamespace(client=Client()) + ) + monkeypatch.setattr(profiles, "store_image", lambda **kwargs: kwargs) + result = profiles.generate_avatar( + prompt="Watercolour portrait", source_image_concept_id="#V#source" + ) + assert len(calls) == 1 + assert result["provenance"]["parent_concept_id"] == "#V#source" + assert result["provenance"]["parent_sha256"] == "source-hash" + assert result["user_concept_id"] == "#V#alice" + + +def test_photo_generation_denies_foreign_source_before_provider(monkeypatch): + from src.backend.languagemodels import openai_client + + monkeypatch.setattr( + profiles, "_participant", lambda *a, **k: ("#V#alice", "#V#alice", {}) + ) + + def denied(*args): + raise PermissionError("Image unavailable") + + monkeypatch.setattr(profiles, "load_image", denied) + monkeypatch.setattr( + openai_client, + "OpenAIClient", + lambda: pytest.fail("No provider request permitted"), + ) + with pytest.raises(PermissionError): + profiles.generate_avatar( + prompt="Portrait", source_image_concept_id="#V#bob_photo" + ) + + +@pytest.mark.parametrize( + "kind,face_count", [("single", 1), ("multiple", 2), ("none", 0)] +) +def test_real_face_framing_and_recoverable_defaults(monkeypatch, kind, face_count): + from pathlib import Path + + portrait = Image.open( + Path(__file__).parents[1] / "fixtures/avatar/astronaut.png" + ).convert("RGB") + if kind == "multiple": + image = Image.new("RGB", (1024, 512)) + image.paste(portrait, (0, 0)) + image.paste(portrait, (512, 0)) + elif kind == "none": + image = Image.new("RGB", (800, 400), "green") + else: + image = portrait + output = io.BytesIO() + image.save(output, "PNG") + monkeypatch.setattr( + profiles, "_participant", lambda *a, **k: ("#V#alice", "#V#alice", {}) + ) + monkeypatch.setattr( + profiles, "store_image", lambda **kw: {"concept_id": "#V#source"} + ) + result = profiles.prepare_avatar(data=output.getvalue(), filename="fixture.png") + assert result["face_detection_available"] + assert result["face_count"] == face_count + crop = result["crop"] + profiles._crop_box(crop, *image.size) + if kind == "single": + assert 180 < crop["x"] + crop["size"] / 2 < 270 + assert 70 < crop["y"] + crop["size"] / 2 < 160 + assert crop["size"] < 250 + else: + assert crop == { + "x": (image.width - image.height) / 2, + "y": 0, + "size": image.height, + } + + +def test_detector_failure_keeps_manual_upload_available(monkeypatch): + from src.backend.services import avatar_framing + + monkeypatch.setattr( + profiles, "_participant", lambda *a, **k: ("#V#alice", "#V#alice", {}) + ) + monkeypatch.setattr( + profiles, "store_image", lambda **kw: {"concept_id": "#V#source"} + ) + + def unavailable(*a): + raise ImportError("fixture unavailable") + + monkeypatch.setattr(avatar_framing, "find_faces", unavailable) + output = io.BytesIO() + Image.new("RGB", (100, 200)).save(output, "PNG") + result = profiles.prepare_avatar(data=output.getvalue(), filename="photo.png") + assert not result["face_detection_available"] + assert result["crop"] == {"x": 0, "y": 50, "size": 100} + + +def test_prepare_requires_self_and_trusted_actor_before_storage(monkeypatch): + monkeypatch.setattr(profiles, "get_effective_user_concept_id", lambda: "#V#alice") + monkeypatch.setattr( + profiles, + "get_effective_user_concept_id_with_source", + lambda: ("#V#alice", "authenticated_session"), + ) + monkeypatch.setattr( + profiles, + "store_image", + lambda **k: pytest.fail("No storage before authorisation"), + ) + with pytest.raises(PermissionError): + profiles.prepare_avatar( + concept_id="#V#bob", data=b"photo", filename="photo.png" + ) + monkeypatch.setattr( + profiles, + "get_effective_user_concept_id_with_source", + lambda: ("#V#alice", "legacy_identity_header"), + ) + with pytest.raises(PermissionError): + profiles.prepare_avatar(data=b"photo", filename="photo.png") + + +def test_profile_http_routes_forward_source_crop_and_return_typed_failures(monkeypatch): + from flask import Blueprint, Flask + + from src.backend.server.routes.participant_profile_routes import ( + register_participant_profile_routes, + ) + + app = Flask(__name__) + blueprint = Blueprint("avatar_test", __name__) + register_participant_profile_routes(blueprint) + app.register_blueprint(blueprint, url_prefix="/von") + calls = [] + monkeypatch.setattr( + profiles, + "prepare_avatar", + lambda **kw: calls.append(kw) or {"image": {"concept_id": "#V#source"}}, + ) + monkeypatch.setattr( + profiles, + "generate_avatar", + lambda **kw: calls.append(kw) or {"concept_id": "#V#derived"}, + ) + monkeypatch.setattr( + profiles, + "set_avatar", + lambda **kw: calls.append(kw) or {"concept_id": "#V#alice"}, + ) + with app.test_client() as client: + result = client.post( + "/von/api/participants/avatar/prepare", + data={ + "file": (io.BytesIO(b"photo"), "photo.png"), + "concept_id": "#V#alice", + }, + ) + assert result.status_code == 200 + assert calls[-1]["data"] == b"photo" + result = client.post( + "/von/api/participants/avatar/generate", + json={ + "concept_id": "#V#alice", + "source_image_concept_id": "#V#source", + "prompt": "Watercolour", + }, + ) + assert result.status_code == 200 + assert calls[-1]["source_image_concept_id"] == "#V#source" + crop = {"x": 10, "y": 20, "size": 100} + result = client.post( + "/von/api/participants/avatar", + json={ + "concept_id": "#V#alice", + "image_concept_id": "#V#source", + "crop": crop, + "scope": "user_only_default", + }, + ) + assert result.status_code == 200 + assert calls[-1]["crop"] == crop + assert calls[-1]["scope"] == "user_only_default" + result = client.post("/von/api/participants/avatar", data={"crop": "{invalid"}) + assert result.status_code == 400 + + def unavailable(**kw): + raise PermissionError("Image unavailable") + + monkeypatch.setattr(profiles, "generate_avatar", unavailable) + assert ( + client.post( + "/von/api/participants/avatar/generate", + json={"prompt": "Portrait", "source_image_concept_id": "#V#foreign"}, + ).status_code + == 403 + ) diff --git a/tests/browser/avatarEditor.cjs b/tests/browser/avatarEditor.cjs new file mode 100644 index 000000000..7f2af74d5 --- /dev/null +++ b/tests/browser/avatarEditor.cjs @@ -0,0 +1,109 @@ +// Real editor/CSS with real detector-produced fixtures. API persistence and the +// image provider are isolated stubs; this is not public OAuth or live generation. +// First: pdm run python -m tests.browser.avatarFixtures DIR (or PYTHONPATH=. path) +// Then: PLAYWRIGHT_BROWSERS_PATH=/tmp/von-playwright node tests/browser/avatarEditor.cjs DIR +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const path = require('node:path'); +const { chromium, expect } = require('@playwright/test'); +const root = path.resolve(__dirname, '../../src/frontend/web/von_interface'); +const evidence = path.resolve(process.argv[2]); +const fixtures = JSON.parse(fs.readFileSync(path.join(evidence, 'prepared.json'))); +const server = http.createServer((req, res) => { + const pathname = new URL(req.url, 'http://localhost').pathname; + if (pathname === '/') { + res.setHeader('Content-Type', 'text/html'); + return res.end(''); + } + const file = pathname.startsWith('/fixture/') ? path.join(evidence, path.basename(pathname)) : path.resolve(root, `.${pathname}`); + if ((!file.startsWith(`${root}/static/`) && !file.startsWith(`${evidence}/`)) || !fs.existsSync(file)) return res.writeHead(404).end(); + res.setHeader('Content-Type', file.endsWith('.css') ? 'text/css' : file.endsWith('.png') ? 'image/png' : 'text/javascript'); + res.end(fs.readFileSync(file)); +}); +(async () => { + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const browser = await chromium.launch(); + const results = []; + try { + for (const width of [1440, 360]) { + const page = await browser.newPage({ viewport: { width, height: 900 } }); + let chosen = 'single', saves = [], generations = [], uploads = 0, failGeneration = false; + const profile = { concept_id: '#V#alice', display_name: 'Alice fixture', can_edit: true, available_scopes: ['user_only_default', 'global_general'] }; + await page.route('**/von/api/**', route => { + const pathname = new URL(route.request().url()).pathname; + let body; + if (pathname.endsWith('/profile')) body = { profile }; + else if (pathname.endsWith('/prepare')) { + assert(route.request().headers()['x-von-window-session']); + assert(route.request().postDataBuffer().includes(Buffer.from('filename='))); + uploads++; + body = fixtures[chosen]; + } else if (pathname.endsWith('/generate')) { + const payload = route.request().postDataJSON(); + generations.push(payload); + if (failGeneration) return route.fulfill({ status: 503, json: { error: 'fixture_unavailable', message: 'Image provider unavailable in this fixture.' } }); + body = { image: { concept_id: '#V#derived', url: '/fixture/single.png' } }; + } else if (pathname.endsWith('/avatar')) { + saves.push(route.request().postDataJSON()); + body = { profile: { ...profile, saved_scope: saves.at(-1).scope, saved_avatar_version: 'fixture' } }; + } else throw new Error(`Unexpected API: ${pathname}`); + return route.fulfill({ json: body }); + }); + await page.goto(`http://127.0.0.1:${server.address().port}`); + await page.getByRole('button', { name: 'Profile and avatar', exact: true }).click(); + const status = page.getByRole('status'); + const use = page.getByRole('button', { name: 'Use avatar', exact: true }); + await page.getByLabel('Upload avatar image').setInputFiles(path.join(evidence, 'single.png')); + await expect(status).toContainText('Face framed'); + assert.equal(saves.length, 0); + await page.screenshot({ path: path.join(evidence, `avatar-${width}-face.png`) }); + await page.getByLabel('Zoom', { exact: true }).fill('4'); + await page.getByLabel('Horizontal position').fill('20'); + await page.getByLabel('Vertical position').fill('30'); + await page.getByLabel('Avatar scope').selectOption('user_only_default'); + await use.click(); + await expect(status).toHaveText('Avatar saved.'); + assert.equal(saves[0].image_concept_id, '#V#single'); + assert.equal(saves[0].scope, 'user_only_default'); + assert.deepEqual(saves[0].crop, { x: 76.8, y: 115.2, size: 128 }); + for (const name of ['multiple', 'none', 'oriented']) { + chosen = name; + await page.locator('.participant-avatar-drop').dispatchEvent('drop', { + dataTransfer: await page.evaluateHandle(({ bytes, name }) => { + const dt = new DataTransfer(); + dt.items.add(new File([new Uint8Array(bytes)], `${name}.png`, { type: 'image/png' })); + return dt; + }, { bytes: [...fs.readFileSync(path.join(evidence, `${name}.png`))], name }) + }); + await expect(status).toContainText(name === 'multiple' ? 'Multiple faces' : name === 'none' ? 'No face' : 'Face framed'); + } + // EXIF-restored preview is the same framing as the upright reference. + assert.deepEqual(fixtures.oriented.crop, fixtures.single.crop); + await page.getByLabel('Avatar image description').fill('A watercolour portrait'); + await page.getByRole('button', { name: 'Generate from photo', exact: true }).click(); + await expect(status).toContainText('Preview ready'); + assert.equal(generations[0].source_image_concept_id, '#V#oriented'); + assert.equal(saves.length, 1); + await use.click(); + await expect(status).toHaveText('Avatar saved.'); + assert.equal(saves[1].image_concept_id, '#V#derived'); + await page.getByRole('button', { name: 'Return to source photo' }).click(); + await expect(page.getByRole('group', { name: 'Adjust framing' })).toBeVisible(); + failGeneration = true; + await page.getByRole('button', { name: 'Generate from photo' }).click(); + await expect(status).toHaveText('Image provider unavailable in this fixture.'); + await expect(use).toBeEnabled(); + await use.click(); + await expect(status).toHaveText('Avatar saved.'); + assert.equal(saves[2].image_concept_id, '#V#oriented'); + const layout = await page.locator('dialog').evaluate(el => ({ width: el.clientWidth, scrollWidth: el.scrollWidth, right: el.getBoundingClientRect().right, viewport: innerWidth })); + assert(layout.scrollWidth <= layout.width + 1 && layout.right <= width); + await page.screenshot({ path: path.join(evidence, `avatar-${width}-manual.png`) }); + results.push({ width, uploads, saves, generations, layout }); + await page.close(); + } + fs.writeFileSync(path.join(evidence, 'browser-results.json'), JSON.stringify(results, null, 2)); + console.log('PASS: desktop/narrow picker, drop, face defaults, manual crop, orientation, source-conditioned request, explicit apply and recoverable provider failure'); + } finally { await browser.close(); server.close(); } +})().catch(error => { console.error(error); server.close(); process.exitCode = 1; }); diff --git a/tests/browser/avatarFixtures.py b/tests/browser/avatarFixtures.py new file mode 100644 index 000000000..7e4990759 --- /dev/null +++ b/tests/browser/avatarFixtures.py @@ -0,0 +1,45 @@ +"""Build photo/crop fixtures with the real local detector, without persistence.""" + +import io +import json +import sys +from pathlib import Path +from unittest.mock import patch + +from PIL import Image + +from src.backend.services import participant_profile_service as profiles + +out = Path(sys.argv[1]) +out.mkdir(parents=True, exist_ok=True) +portrait = Image.open( + Path(__file__).parents[1] / "fixtures/avatar/astronaut.png" +).convert("RGB") +multiple = Image.new("RGB", (1024, 512)) +multiple.paste(portrait, (0, 0)) +multiple.paste(portrait, (512, 0)) +# Encoded rotated, with EXIF restoring the upright portrait. +rotated = portrait.transpose(Image.Transpose.ROTATE_90) +records = {} +for name, image in [ + ("single", portrait), + ("multiple", multiple), + ("none", Image.new("RGB", (800, 400), "green")), + ("oriented", rotated), +]: + data = io.BytesIO() + exif = Image.Exif() + if name == "oriented": + exif[274] = 6 + image.save(data, "PNG", exif=exif) + raw = data.getvalue() + (out / f"{name}.png").write_bytes(raw) + descriptor = {"concept_id": f"#V#{name}", "url": f"/fixture/{name}.png"} + with ( + patch.object( + profiles, "_participant", return_value=("#V#alice", "#V#alice", {}) + ), + patch.object(profiles, "store_image", return_value=descriptor), + ): + records[name] = profiles.prepare_avatar(data=raw, filename=f"{name}.png") +(out / "prepared.json").write_text(json.dumps(records, indent=2)) diff --git a/tests/fixtures/avatar/README.md b/tests/fixtures/avatar/README.md new file mode 100644 index 000000000..53f56e054 --- /dev/null +++ b/tests/fixtures/avatar/README.md @@ -0,0 +1,9 @@ +# Avatar framing fixture + +`astronaut.png` is NASA's portrait of astronaut Eileen Collins, distributed by +scikit-image 0.24.0 as a public-domain image. + +Source: https://github.com/scikit-image/scikit-image/blob/v0.24.0/skimage/data/astronaut.png +Provenance: https://github.com/scikit-image/scikit-image/blob/v0.24.0/skimage/data/_fetchers.py (`astronaut`) + +Used only to test face location and adjustable framing, never identity inference. diff --git a/tests/fixtures/avatar/astronaut.png b/tests/fixtures/avatar/astronaut.png new file mode 100644 index 000000000..834cda001 Binary files /dev/null and b/tests/fixtures/avatar/astronaut.png differ