diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..d3b3617f19a5f14c3caa7bb71916ce3c04e6f585 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +.git +.gitignore +.venv +venv +__pycache__ +*.pyc +*.pyo +*.pyd + +# Secrets / local config +.env +.env.local + +# Large or non-runtime artifacts +fivek_dataset +renders +result.jpg +image_edits.html +lrcat_inspect.html +streamlit_output.jpg +streamlit_output.png +_streamlit_input.jpg +_streamlit_input.png +_streamlit_input.dng +_streamlit_input.heic +_streamlit_input.heif +llm_recipe_*.json +my_recipe.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..07ffe0e2ca647f9d3e78863b962bc91409e79a07 --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# FiveK dataset subset size (number of images to index) +FIVEK_SUBSET_SIZE=500 + +# Paths (optional; defaults relative to project root) +# FIVEK_DATASET_DIR=./fivek_dataset +# FIVEK_LRCAT_PATH=./fivek_dataset/raw_photos/fivek.lrcat +# FIVEK_RAW_PHOTOS_DIR=./fivek_dataset/raw_photos + +# Azure AI Search (for vector index) +# AZURE_SEARCH_ENDPOINT=https://.search.windows.net +# AZURE_SEARCH_KEY= +# AZURE_SEARCH_INDEX_NAME=fivek-vectors + +# Embedding: local CLIP (uses Mac MPS / CUDA when available) or Azure Vision +# EMBEDDING_MODEL=openai/clip-vit-base-patch32 +# EMBEDDING_DIM=512 + +# Optional: Azure AI Vision multimodal embeddings (skips local CLIP; no GPU needed) +# AZURE_VISION_ENDPOINT=https://.cognitiveservices.azure.com +# AZURE_VISION_KEY= +# AZURE_VISION_MODEL_VERSION=2023-04-15 + +# Azure OpenAI (LLM for pipeline: analyze image + expert recipe β†’ suggested edits) +# AZURE_OPENAI_ENDPOINT=https://.cognitiveservices.azure.com/ +# AZURE_OPENAI_KEY= +# AZURE_OPENAI_DEPLOYMENT=gpt-4o +# AZURE_OPENAI_API_VERSION=2024-12-01-preview + +# Optional: external editing API (if set, run_pipeline.py --api calls it; else edits applied locally) +# EDITING_API_URL=https://photo-editing-xxx.azurewebsites.net/api/apply-edits diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..c3db928435c6502d69e972a655ea744dc08e3f1b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +test_input.jpg diff --git a/.streamlit/config.toml b/.streamlit/config.toml new file mode 100644 index 0000000000000000000000000000000000000000..c4dad319eb955f5ad272f0b80aa82278a28bf706 --- /dev/null +++ b/.streamlit/config.toml @@ -0,0 +1,7 @@ +[theme] +base = "dark" +primaryColor = "#60A5FA" +backgroundColor = "#0F172A" +secondaryBackgroundColor = "#111827" +textColor = "#F1F5F9" +font = "sans serif" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..7d39bd0c1ab723096df91b3b146d797e6bd2bd79 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PORT=7860 + +WORKDIR /app + +# Runtime libraries commonly needed by image stacks (rawpy/Pillow/scikit-image). +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgl1 \ + libglib2.0-0 \ + libgomp1 \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --upgrade pip && pip install -r requirements.txt + +COPY . . + +EXPOSE 7860 + +CMD ["streamlit", "run", "app.py", "--server.address=0.0.0.0", "--server.port=7860"] diff --git a/README.md b/README.md index 9cbc29733b795ad90bb3379cb59acb244e9d8fb4..8126b0e386d5e56da10e1f8c5e3b5b5359d2354b 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,233 @@ --- title: PixelPilotAI -emoji: πŸ“ˆ +emoji: πŸ“· colorFrom: purple -colorTo: purple +colorTo: blue sdk: docker pinned: false --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# Photo Editing Recommendation Agent + +Recommend global photo edits by retrieving similar expert-edited examples (MIT–Adobe FiveK), aggregating Expert A recipes, and applying them deterministically. This repo is structured so **dataset β†’ embeddings β†’ vector DB** and the **inference API + LLM** can be developed in parallel and merged cleanly. + +## Project layout (merge-ready) + +``` +PhotoEditor/ +β”œβ”€β”€ .env # Copy from .env.example; set FIVEK_SUBSET_SIZE, Azure Search, etc. +β”œβ”€β”€ .env.example +β”œβ”€β”€ requirements.txt +β”œβ”€β”€ photo_editor/ # Core package (shared by pipeline and future API) +β”‚ β”œβ”€β”€ config/ # Settings from env (paths, Azure, subset size) +β”‚ β”œβ”€β”€ dataset/ # FiveK paths, subset selection (filesAdobe.txt) +β”‚ β”œβ”€β”€ lrcat/ # Lightroom catalog: Expert A recipe extraction +β”‚ β”œβ”€β”€ images/ # DNG β†’ RGB (rawpy, neutral development) +β”‚ β”œβ”€β”€ embeddings/ # CLIP image embeddings (index + query) +β”‚ └── vector_store/ # Azure AI Search index (upload + search) +β”œβ”€β”€ scripts/ +β”‚ └── build_vector_index.py # Build vector index: subset β†’ embed β†’ push to Azure +β”œβ”€β”€ fivek_dataset/ # MIT–Adobe FiveK (file lists, raw_photos/, fivek.lrcat) +β”œβ”€β”€ LLM.py # Existing Azure GPT-4o explanation layer (to be wired to RAG) +└── api/ # (Future) FastAPI: /analyze-image, /apply-edits, /edit-and-explain +``` + +- **Inference merge**: The API will use `photo_editor.vector_store.AzureSearchVectorStore` for retrieval, `photo_editor.embeddings` for query embedding, and `LLM.py` (or a moved `photo_editor.llm`) for explanations. Apply-edits will use a separate editing engine (OpenCV/Pillow) consuming `EditRecipe` from `photo_editor.lrcat.schema`. + +## Dataset β†’ Vector DB (this slice) + +1. **Subset**: First `FIVEK_SUBSET_SIZE` images from `fivek_dataset/filesAdobe.txt` (default 500; set in `.env`). +2. **Edits**: Expert A only; recipes read from `fivek.lrcat` (virtual copy "Copy 1"). +3. **Embeddings**: Original DNG β†’ neutral development β†’ RGB β†’ CLIP (`openai/clip-vit-base-patch32`). +4. **Vector DB**: Azure AI Search index (created if missing); each document = `id`, `image_id`, `embedding`, `recipe` (JSON). + +### Setup + +```bash +cp .env.example .env +# Edit .env: FIVEK_SUBSET_SIZE (e.g. 500), AZURE_SEARCH_*, optional paths +pip install -r requirements.txt +``` + +### Build the index + +From the project root: + +```bash +PYTHONPATH=. python scripts/build_vector_index.py +``` + +- Requires the FiveK `raw_photos` folder (DNGs + `fivek.lrcat`) under `fivek_dataset/`. +- If Azure Search is not configured in `.env`, the script still runs and skips upload (prints a reminder). + +## How to run things + +All commands below assume you are in the **project root** (`PhotoEditor/`) and have: + +- created and edited `.env` (see config table below), and +- installed dependencies: + +```bash +pip install -r requirements.txt +``` + +## Deploy (Streamlit Cloud + Hugging Face Spaces) + +For cloud deploy, keep the repo minimal and include only runtime files: + +- `app.py` +- `photo_editor/` +- `requirements.txt` +- `.streamlit/config.toml` +- `.env.example` (template only, no secrets) + +Do not commit local artifacts or large datasets (`fivek_dataset/`, `renders/`, generated images/html/json, `.env`). + +### Streamlit Community Cloud + +1. Push this repo to GitHub. +2. In Streamlit Cloud, create a new app from the repo. +3. Set the app file path to `app.py`. +4. Add required secrets in the app settings (same keys as in `.env.example`, e.g. `AZURE_SEARCH_*`, `AZURE_OPENAI_*`). +5. Deploy. + +### Hugging Face Spaces (Streamlit SDK) + +1. Create a new Space and choose **Streamlit** SDK. +2. Point it to this repository (or push these files to the Space repo). +3. Ensure `app.py` is at repo root and `requirements.txt` is present. +4. Add secrets in Space Settings (same variables as `.env.example`). +5. Launch the Space. + +Optional automation: sync supported secrets from local `.env` directly to your Space: + +```bash +pip install huggingface_hub +HF_TOKEN=hf_xxx python scripts/sync_hf_secrets.py --space-id +``` + +### Hugging Face Spaces (Docker SDK) + +This repo now includes a production-ready `Dockerfile` that serves the app on port `7860`. + +1. Create a new Space and choose **Docker** SDK. +2. Push this repository to that Space. +3. In Space Settings, add secrets (or sync them later with `scripts/sync_hf_secrets.py`). +4. Build and launch the Space. + +Local Docker test: + +```bash +docker build -t lumigrade-ai . +docker run --rm -p 7860:7860 --env-file .env lumigrade-ai +``` + +### 1. Run the Streamlit UI (full app) + +Interactive app to upload an image (JPEG/PNG) or point to a DNG on disk, then run the full pipeline and see **original vs edited** plus the suggested edit parameters. + +```bash +streamlit run app.py +``` + +This will: +- Check Azure Search + Azure OpenAI config from `.env`. +- For each run: retrieve similar experts β†’ call LLM for summary + suggested edits β†’ apply edits (locally or via external API) β†’ show before/after. + +### 2. Run the full pipeline from the terminal + +Run the same pipeline as the UI, but from the CLI for a single image: + +```bash +python scripts/run_pipeline.py [--out output.jpg] [--api] [-v] +``` + +Examples: + +```bash +# Run pipeline locally, save to result.jpg, print summary + suggested edits +python scripts/run_pipeline.py photo.jpg --out result.jpg -v + +# Run pipeline but use an external editing API (requires EDITING_API_URL in .env) +python scripts/run_pipeline.py photo.jpg --out result.jpg --api -v +``` + +What `-v` prints: +- πŸ“‹ **Summary** of what the LLM thinks should be done. +- πŸ“ **Suggested edits**: the numeric recipe (exposure, contrast, temp, etc.) coming from Azure OpenAI for that image. +- πŸ“Ž **Expert used**: which FiveK expert image/recipe was used as reference. + +### 3. Just retrieve similar experts (no LLM / no edits) + +If you only want to see which FiveK images are closest to a given photo and inspect their stored recipes: + +```bash +python scripts/query_similar.py [--top-k 50] [--top-n 5] +``` + +Examples: + +```bash +# Show the best 5 expert matches (default top-k=50 search space) +python scripts/query_similar.py photo.jpg --top-n 5 + +# Show only the single best match +python scripts/query_similar.py photo.jpg --top-n 1 +``` + +Output: +- Ranks (`1.`, `2.`, …), image_ids, rerank scores. +- The stored **Expert A recipe** JSON for each match. + +### 4. Get the exact Expert A recipe for a FiveK image + +Given a FiveK `image_id` (with or without extension), extract the Expert A recipe directly from the Lightroom catalog: + +```bash +python scripts/get_recipe_for_image.py [-o recipe.json] +``` + +Examples: + +```bash +# Print the recipe as JSON +python scripts/get_recipe_for_image.py a0001-jmac_DSC1459 + +# Save the recipe to a file +python scripts/get_recipe_for_image.py a0001-jmac_DSC1459 -o my_recipe.json +``` + +### 5. Apply a custom (LLM) recipe to a FiveK image + +If you already have a JSON recipe (for example, something you crafted or got from the LLM) and want to apply it to a FiveK RAW image using the same rendering pipeline: + +```bash +python scripts/apply_llm_recipe.py [--out path.jpg] +``` + +Example: + +```bash +python scripts/apply_llm_recipe.py a0059-JI2E5556 llm_recipe_a0059.json --out renders/a0059-JI2E5556_LLM.jpg +``` + +This will: +- Load the DNG for ``. +- Use `dng_to_rgb_normalized` to bake in exposure/brightness from the recipe. +- Apply the rest of the recipe (contrast, temperature, etc.) on top of the original Expert A baseline. +- Save the rendered JPEG. + +## Config (.env) + +| Variable | Description | +|--------|-------------| +| `FIVEK_SUBSET_SIZE` | Number of images to index (default 500). | +| `FIVEK_LRCAT_PATH` | Path to `fivek.lrcat` (default: `fivek_dataset/raw_photos/fivek.lrcat`). | +| `FIVEK_RAW_PHOTOS_DIR` | Root of range folders (e.g. `HQa1to700`, …). | +| `AZURE_SEARCH_ENDPOINT` | Azure AI Search endpoint URL. | +| `AZURE_SEARCH_KEY` | Azure AI Search admin key. | +| `AZURE_SEARCH_INDEX_NAME` | Index name (default `fivek-vectors`). | + +## License / data + +See `fivek_dataset/LICENSE.txt` and related notices for the MIT–Adobe FiveK dataset. diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..684ad514eb6974fed7fae4ad610eb2e8cfccf3cd --- /dev/null +++ b/app.py @@ -0,0 +1,351 @@ +#!/usr/bin/env python3 +""" +Streamlit UI for the photo editing pipeline. +Upload an image (or use a file path for DNG), run retrieve β†’ LLM β†’ apply, view result. + +Run from project root: + streamlit run app.py +""" +import sys +from pathlib import Path + +_PROJECT_ROOT = Path(__file__).resolve().parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +import numpy as np +import streamlit as st + +from photo_editor.config import get_settings +from photo_editor.images import dng_to_rgb +from photo_editor.images.estimate_current_recipe import estimate_current_parameters +from photo_editor.pipeline.run import run_pipeline + +# Fixed paths so "last result" matches across reruns (upload overwrites same file) +_STREAMLIT_INPUT_JPG_PATH = _PROJECT_ROOT / "_streamlit_input.jpg" +_STREAMLIT_INPUT_PNG_PATH = _PROJECT_ROOT / "_streamlit_input.png" +_STREAMLIT_INPUT_DNG_PATH = _PROJECT_ROOT / "_streamlit_input.dng" +_STREAMLIT_INPUT_HEIC_PATH = _PROJECT_ROOT / "_streamlit_input.heic" +_STREAMLIT_INPUT_HEIF_PATH = _PROJECT_ROOT / "_streamlit_input.heif" +# Use PNG output for UI preview to avoid JPEG quality loss. +_STREAMLIT_OUTPUT_PATH = _PROJECT_ROOT / "streamlit_output.png" +# Reversible toggle: set to False to restore top-1-only expert context. +_USE_MULTI_EXPERT_CONTEXT = True +_MULTI_EXPERT_CONTEXT_TOP_N = 1 +_USE_BRIGHTNESS_GUARDRAIL = True + + +def _load_original_for_display(image_path: Path): + """Load image for display. Use rawpy for DNG so 'Original' matches pipeline quality.""" + path = Path(image_path) + if path.suffix.lower() == ".dng": + rgb = dng_to_rgb(path, output_size=None) # full resolution, same develop as pipeline + rgb_u8 = (np.clip(rgb, 0, 1) * 255).astype(np.uint8) + return rgb_u8 + # JPEG/PNG/HEIC/HEIF: Streamlit/Pillow can show from path (with plugin support). + return str(path) + + + +def main() -> None: + st.set_page_config(page_title="LumiGrade AI", page_icon="πŸ“·", layout="wide") + st.markdown( + """ + +""", + unsafe_allow_html=True, + ) + + st.title("πŸ“· LumiGrade AI") + st.caption("Upload an image to get expert-informed edit recommendations and an instant enhanced result.") + + # Config check + s = get_settings() + if not s.azure_search_configured(): + st.error("Azure AI Search not configured. Set AZURE_SEARCH_ENDPOINT and AZURE_SEARCH_KEY in .env") + st.stop() + if not s.azure_openai_configured(): + st.error("Azure OpenAI not configured. Set AZURE_OPENAI_* in .env") + st.stop() + + # External editing API toggle has been removed from the UI for simplicity. + # If you want to use the external API again, you can reintroduce a sidebar + # control and wire it to this flag. + use_editing_api = False + + image_path = None + + with st.sidebar: + # Reliable spacing so only the Pipeline Inputs card moves down. + st.markdown('
', unsafe_allow_html=True) + with st.container(border=True): + st.markdown('
Pipeline Inputs
', unsafe_allow_html=True) + uploaded = st.file_uploader( + "Upload JPEG, PNG, DNG, HEIC, or HEIF", + type=["jpg", "jpeg", "png", "dng", "heic", "heif"], + help="You can upload DNG/HEIC directly, or use a local path below.", + ) + if uploaded is not None: + suffix = Path(uploaded.name).suffix.lower() + if suffix == ".dng": + target = _STREAMLIT_INPUT_DNG_PATH + elif suffix == ".heic": + target = _STREAMLIT_INPUT_HEIC_PATH + elif suffix == ".heif": + target = _STREAMLIT_INPUT_HEIF_PATH + elif suffix == ".png": + target = _STREAMLIT_INPUT_PNG_PATH + else: + target = _STREAMLIT_INPUT_JPG_PATH + target.write_bytes(uploaded.getvalue()) + image_path = target + else: + path_str = st.text_input( + "Or enter path to image (e.g. for DNG)", + placeholder="/path/to/image.dng or image.jpg", + ) + path_str = (path_str or "").strip() + if path_str: + p = Path(path_str) + if p.exists(): + image_path = p + else: + st.warning("File not found. Use a full path that exists, or upload a file.") + + run_clicked = st.button("β–Ά Run Pipeline", type="primary", use_container_width=True) + status = st.empty() + if image_path is None: + status.info("Provide an image to run.") + + if run_clicked and image_path is not None: + loading_box = st.empty() + + def _render_loading(current_stage: str, state: str = "running") -> None: + stage_order = ["retrieving", "consulting", "applying"] + stage_labels = { + "retrieving": "Analyzing similar expert edits", + "consulting": "Generating personalized recommendations", + "applying": "Rendering your enhanced preview", + } + current_idx = stage_order.index(current_stage) if current_stage in stage_order else 0 + + if state == "done": + title = "Done" + spinner_html = "" + elif state == "failed": + title = "Pipeline failed" + spinner_html = "" + else: + title = "Running pipeline" + spinner_html = '' + + lines = [] + for i, key in enumerate(stage_order): + if state == "done": + icon = "βœ…" + elif state == "failed" and i > current_idx: + icon = "⏳" + else: + icon = "βœ…" if i < current_idx else ("πŸ”„" if i == current_idx and state == "running" else "⏳") + lines.append(f'
{icon} {stage_labels[key]}
') + + loading_box.markdown( + f""" +
+
{spinner_html}{title}
+ {''.join(lines)} +
+""", + unsafe_allow_html=True, + ) + + _render_loading("retrieving", "running") + try: + current_params = estimate_current_parameters(image_path) + result = run_pipeline( + image_path, + _STREAMLIT_OUTPUT_PATH, + top_k=50, + top_n=1, + use_editing_api=use_editing_api, + use_multi_expert_context=_USE_MULTI_EXPERT_CONTEXT, + context_top_n=_MULTI_EXPERT_CONTEXT_TOP_N, + use_brightness_guardrail=_USE_BRIGHTNESS_GUARDRAIL, + progress_callback=lambda stage: _render_loading(stage, "running"), + ) + if result.get("success"): + st.session_state["pipeline_result"] = result + st.session_state["pipeline_output_path"] = _STREAMLIT_OUTPUT_PATH + st.session_state["pipeline_input_path"] = str(image_path) + st.session_state["pipeline_current_params"] = current_params + status.success("Done!") + _render_loading("applying", "done") + else: + st.session_state.pop("pipeline_result", None) + st.session_state.pop("pipeline_output_path", None) + st.session_state.pop("pipeline_input_path", None) + st.session_state.pop("pipeline_current_params", None) + status.error("Editing step failed.") + _render_loading("applying", "failed") + except Exception as e: + status.error("Pipeline failed.") + st.exception(e) + st.session_state.pop("pipeline_result", None) + st.session_state.pop("pipeline_output_path", None) + st.session_state.pop("pipeline_input_path", None) + st.session_state.pop("pipeline_current_params", None) + _render_loading("consulting", "failed") + + with st.container(border=True): + st.subheader("Results Dashboard") + st.markdown("### πŸ“Š Pipeline Analysis & Recommendations") + + # Show result if available + if ( + image_path is not None + and st.session_state.get("pipeline_result") + and st.session_state.get("pipeline_input_path") == str(image_path) + ): + result = st.session_state["pipeline_result"] + out_path = st.session_state["pipeline_output_path"] + + if out_path.exists(): + summary = result.get("summary", "") + suggested = result.get("suggested_edits", {}) + expert_id = result.get("expert_image_id", "") + current_params = st.session_state.get("pipeline_current_params") or {} + + with st.expander("AI Analysis Summary", expanded=True): + st.markdown(summary) + + with st.expander("Parameters: Details", expanded=True): + st.markdown("#### Parameters: Current vs Suggested vs Delta") + keys = [ + "exposure", + "contrast", + "highlights", + "shadows", + "whites", + "blacks", + "temperature", + "tint", + "vibrance", + "saturation", + ] + rows = [] + for k in keys: + cur = current_params.get(k, None) + sug = suggested.get(k, None) + try: + cur_f = float(cur) if cur is not None else None + except Exception: + cur_f = None + try: + sug_f = float(sug) if sug is not None else None + except Exception: + sug_f = None + delta = (sug_f - cur_f) if (sug_f is not None and cur_f is not None) else None + rows.append( + { + "parameter": k, + "current_estimated": cur_f, + "suggested": sug_f, + "delta": delta, + } + ) + st.dataframe(rows, use_container_width=True, hide_index=True) + st.caption('β€œCurrent” values are estimated from pixels (not true Lightroom sliders).') + else: + st.info("Run the pipeline to populate results.") + else: + st.info("Run the pipeline from the left pane to view analysis and recommendations.") + + # Keep this full-width and at the bottom, per request. + if ( + image_path is not None + and st.session_state.get("pipeline_result") + and st.session_state.get("pipeline_input_path") == str(image_path) + ): + result = st.session_state["pipeline_result"] + out_path = st.session_state["pipeline_output_path"] + if out_path.exists(): + st.markdown("---") + st.subheader("Original vs Result") + col_orig, col_result = st.columns(2) + with col_orig: + st.image(_load_original_for_display(image_path), caption="Original", use_container_width=True) + with col_result: + st.image(str(out_path), caption="Edited", use_container_width=True) + + +if __name__ == "__main__": + main() diff --git a/photo_editor/__init__.py b/photo_editor/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..38c02e4edd292a8c9c37861a6e2eb6dc26ea57e4 --- /dev/null +++ b/photo_editor/__init__.py @@ -0,0 +1,14 @@ +""" +Photo Editing Recommendation Agent – core package. + +Subpackages: +- config: settings from env (paths, Azure, subset size) +- dataset: FiveK paths and subset selection +- lrcat: Lightroom catalog extraction (Expert A recipes) +- images: DNG β†’ RGB development +- embeddings: image embedding (CLIP) +- vector_store: Azure AI Search index (for RAG retrieval) +- pipeline: end-to-end run (retrieve β†’ LLM β†’ apply) +""" + +__version__ = "0.1.0" diff --git a/photo_editor/__pycache__/__init__.cpython-313.pyc b/photo_editor/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2a10dc37396f2be4e985f7633d92f5d2e75f9f95 Binary files /dev/null and b/photo_editor/__pycache__/__init__.cpython-313.pyc differ diff --git a/photo_editor/config/__init__.py b/photo_editor/config/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..658200d5517919f8ef1778f1cfee848aadb68d98 --- /dev/null +++ b/photo_editor/config/__init__.py @@ -0,0 +1,3 @@ +from .settings import get_settings, settings + +__all__ = ["get_settings", "settings"] diff --git a/photo_editor/config/__pycache__/__init__.cpython-313.pyc b/photo_editor/config/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4463fc2d298da21397da7a1a05ad39dd01ce2765 Binary files /dev/null and b/photo_editor/config/__pycache__/__init__.cpython-313.pyc differ diff --git a/photo_editor/config/__pycache__/settings.cpython-313.pyc b/photo_editor/config/__pycache__/settings.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..49f14a6b33f436a7a7686138fe21cc038956d338 Binary files /dev/null and b/photo_editor/config/__pycache__/settings.cpython-313.pyc differ diff --git a/photo_editor/config/settings.py b/photo_editor/config/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..92119c74914a2f8eb3806d1d851a654730fffcc8 --- /dev/null +++ b/photo_editor/config/settings.py @@ -0,0 +1,110 @@ +""" +Application settings loaded from environment. +Shared by: dataset pipeline, vector index build, and future API/LLM. +""" +import os +from pathlib import Path +from typing import Optional + +from dotenv import load_dotenv + +# Load .env from project root (parent of photo_editor) +_PROJECT_ROOT = Path(__file__).resolve().parents[2] +load_dotenv(_PROJECT_ROOT / ".env") + + +def _str(name: str, default: Optional[str] = None) -> str: + v = os.getenv(name, default) + return v.strip() if v else (default or "") + + +def _int(name: str, default: int = 0) -> int: + v = os.getenv(name) + if v is None or v.strip() == "": + return default + try: + return int(v.strip()) + except ValueError: + return default + + +def _path(name: str, default: Optional[Path] = None) -> Path: + v = _str(name) + if not v: + return default or _PROJECT_ROOT + p = Path(v) + if not p.is_absolute(): + p = _PROJECT_ROOT / p + return p + + +class Settings: + """Central settings; use get_settings() for a singleton.""" + + def __init__(self) -> None: + self.project_root = _PROJECT_ROOT + + # FiveK + self.fivek_subset_size = _int("FIVEK_SUBSET_SIZE", 500) + self.fivek_dataset_dir = _path("FIVEK_DATASET_DIR", _PROJECT_ROOT / "fivek_dataset") + self.fivek_lrcat_path = _path( + "FIVEK_LRCAT_PATH", + _PROJECT_ROOT / "fivek_dataset" / "raw_photos" / "fivek.lrcat", + ) + self.fivek_raw_photos_dir = _path( + "FIVEK_RAW_PHOTOS_DIR", + _PROJECT_ROOT / "fivek_dataset" / "raw_photos", + ) + self.fivek_file_list = self.fivek_dataset_dir / "filesAdobe.txt" + + # Azure AI Search + self.azure_search_endpoint = _str("AZURE_SEARCH_ENDPOINT") + self.azure_search_key = _str("AZURE_SEARCH_KEY") + self.azure_search_index_name = _str("AZURE_SEARCH_INDEX_NAME", "fivek-vectors") + + # Embedding (model name and dimension for index schema) + self.embedding_model = _str( + "EMBEDDING_MODEL", + "openai/clip-vit-base-patch32", + ) + self.embedding_dim = _int("EMBEDDING_DIM", 512) + + # Optional: Azure AI Vision multimodal embeddings (use instead of local CLIP) + self.azure_vision_endpoint = _str("AZURE_VISION_ENDPOINT") + self.azure_vision_key = _str("AZURE_VISION_KEY") + self.azure_vision_model_version = _str("AZURE_VISION_MODEL_VERSION", "2023-04-15") + + # Azure OpenAI (LLM for pipeline) + self.azure_openai_endpoint = _str("AZURE_OPENAI_ENDPOINT") + self.azure_openai_key = _str("AZURE_OPENAI_KEY") + self.azure_openai_deployment = _str("AZURE_OPENAI_DEPLOYMENT", "gpt-4o") + self.azure_openai_api_version = _str("AZURE_OPENAI_API_VERSION", "2024-12-01-preview") + + # Optional: external editing API (if set, pipeline calls it; else uses local apply) + self.editing_api_url = _str("EDITING_API_URL") + + def azure_search_configured(self) -> bool: + return bool(self.azure_search_endpoint and self.azure_search_key) + + def azure_vision_configured(self) -> bool: + return bool(self.azure_vision_endpoint and self.azure_vision_key) + + def azure_openai_configured(self) -> bool: + return bool(self.azure_openai_endpoint and self.azure_openai_key) + + def editing_api_configured(self) -> bool: + return bool(self.editing_api_url) + + +_settings: Optional[Settings] = None + + +def get_settings() -> Settings: + global _settings + if _settings is None: + _settings = Settings() + return _settings + + +# Convenience singleton +settings = get_settings() diff --git a/photo_editor/dataset/__init__.py b/photo_editor/dataset/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f0fd1b2aecf620a697f2b0ff2227a7401a28d833 --- /dev/null +++ b/photo_editor/dataset/__init__.py @@ -0,0 +1,4 @@ +from .paths import image_id_to_dng_path, list_all_image_ids +from .subset import get_subset_image_ids + +__all__ = ["image_id_to_dng_path", "list_all_image_ids", "get_subset_image_ids"] diff --git a/photo_editor/dataset/__pycache__/__init__.cpython-313.pyc b/photo_editor/dataset/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f2baf15bbead782c21b5474f5d82b21ce0810c76 Binary files /dev/null and b/photo_editor/dataset/__pycache__/__init__.cpython-313.pyc differ diff --git a/photo_editor/dataset/__pycache__/paths.cpython-313.pyc b/photo_editor/dataset/__pycache__/paths.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ac8635fdf828ed0f44867993f29930b6d16e988f Binary files /dev/null and b/photo_editor/dataset/__pycache__/paths.cpython-313.pyc differ diff --git a/photo_editor/dataset/__pycache__/subset.cpython-313.pyc b/photo_editor/dataset/__pycache__/subset.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..47f643eb12dd290ba589d7594f7b4c6815abffa3 Binary files /dev/null and b/photo_editor/dataset/__pycache__/subset.cpython-313.pyc differ diff --git a/photo_editor/dataset/paths.py b/photo_editor/dataset/paths.py new file mode 100644 index 0000000000000000000000000000000000000000..b45bf53a91ba0022530f22edfe7599fa2416e5b4 --- /dev/null +++ b/photo_editor/dataset/paths.py @@ -0,0 +1,53 @@ +""" +FiveK dataset path resolution. +Maps image ID (e.g. a2621-_DSC5468) to DNG path using range folders. +""" +from pathlib import Path +from typing import List + +# Range folders under raw_photos (inclusive low–high) +_RANGES = [ + (1, 700, "HQa1to700"), + (701, 1400, "HQa701to1400"), + (1401, 2100, "HQa1400to2100"), + (2101, 2800, "HQa2101to2800"), + (2801, 3500, "HQa2801to3500"), + (3501, 4200, "HQa3501to4200"), + (4201, 5000, "HQa4201to5000"), +] + + +def _id_to_number(image_id: str) -> int: + """Extract numeric part from id, e.g. a2621-_DSC5468 -> 2621.""" + prefix = image_id.split("-")[0] + if prefix.startswith("a"): + try: + return int(prefix[1:]) + except ValueError: + pass + return 0 + + +def image_id_to_dng_path(image_id: str, raw_photos_dir: Path) -> Path: + """ + Resolve image ID to DNG file path under raw_photos_dir. + Returns path like raw_photos_dir/HQa2101to2800/photos/a2621-_DSC5468.dng + """ + num = _id_to_number(image_id) + for low, high, folder_name in _RANGES: + if low <= num <= high: + return raw_photos_dir / folder_name / "photos" / f"{image_id}.dng" + return raw_photos_dir / "HQa1to700" / "photos" / f"{image_id}.dng" + + +def list_all_image_ids(file_list_path: Path) -> List[str]: + """Read ordered list of image IDs from filesAdobe.txt (or similar).""" + if not file_list_path.exists(): + return [] + ids = [] + with open(file_list_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#"): + ids.append(line) + return ids diff --git a/photo_editor/dataset/subset.py b/photo_editor/dataset/subset.py new file mode 100644 index 0000000000000000000000000000000000000000..ec15c900fabb4942acdc59a62252ef4be17f8d75 --- /dev/null +++ b/photo_editor/dataset/subset.py @@ -0,0 +1,19 @@ +""" +Subset selection: first N image IDs from the canonical list. +N comes from settings (FIVEK_SUBSET_SIZE). +""" +from typing import List + +from photo_editor.config import get_settings +from photo_editor.dataset.paths import list_all_image_ids + + +def get_subset_image_ids() -> List[str]: + """ + Return the first FIVEK_SUBSET_SIZE image IDs from filesAdobe.txt. + Order is deterministic for reproducibility. + """ + s = get_settings() + all_ids = list_all_image_ids(s.fivek_file_list) + n = min(max(1, s.fivek_subset_size), len(all_ids)) + return all_ids[:n] diff --git a/photo_editor/embeddings/__init__.py b/photo_editor/embeddings/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4263d7c2dea146ee9c615c7e2b9634602913cdb9 --- /dev/null +++ b/photo_editor/embeddings/__init__.py @@ -0,0 +1,3 @@ +from .embedder import AzureVisionEmbedder, ImageEmbedder, get_embedder + +__all__ = ["AzureVisionEmbedder", "ImageEmbedder", "get_embedder"] diff --git a/photo_editor/embeddings/__pycache__/__init__.cpython-313.pyc b/photo_editor/embeddings/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4016ca7ebe90774349c156245790d507d729b6e1 Binary files /dev/null and b/photo_editor/embeddings/__pycache__/__init__.cpython-313.pyc differ diff --git a/photo_editor/embeddings/__pycache__/embedder.cpython-313.pyc b/photo_editor/embeddings/__pycache__/embedder.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5da516a675cee7d02d7ec7eadb0a152428c8bfb4 Binary files /dev/null and b/photo_editor/embeddings/__pycache__/embedder.cpython-313.pyc differ diff --git a/photo_editor/embeddings/embedder.py b/photo_editor/embeddings/embedder.py new file mode 100644 index 0000000000000000000000000000000000000000..f66240afaac47eb06c5e7a08c828546ffc0fb97a --- /dev/null +++ b/photo_editor/embeddings/embedder.py @@ -0,0 +1,208 @@ +""" +Image embedding: local CLIP (with Mac MPS / CUDA) or Azure AI Vision multimodal. +Same model must be used at index time and query time for retrieval. +""" +import logging +from pathlib import Path +from typing import List, Union + +import numpy as np + +from photo_editor.config import get_settings + +logger = logging.getLogger(__name__) + + +class AzureVisionEmbedder: + """Encode images via Azure AI Vision retrieval:vectorizeImage API.""" + + def __init__( + self, + endpoint: str, + key: str, + model_version: str = "2023-04-15", + ): + self.endpoint = endpoint.rstrip("/") + self.key = key + self.model_version = model_version + self._dim: Union[int, None] = None + + @property + def dimension(self) -> int: + if self._dim is not None: + return self._dim + # Get dimension from one dummy call (or set from known model: 1024 for 2023-04-15) + import io + from PIL import Image + dummy = np.zeros((224, 224, 3), dtype=np.uint8) + pil = Image.fromarray(dummy) + buf = io.BytesIO() + pil.save(buf, format="JPEG") + v = self._vectorize_image_bytes(buf.getvalue()) + self._dim = len(v) + return self._dim + + def _vectorize_image_bytes(self, image_bytes: bytes) -> List[float]: + import json + import urllib.error + import urllib.request + + # Production API only. 2023-02-01-preview returns 410 Gone (deprecated). + # Docs: https://learn.microsoft.com/en-us/rest/api/computervision/vectorize/image-stream + # Path: POST /computervision/retrieval:vectorizeImage?overload=stream&model-version=...&api-version=2024-02-01 + url = ( + f"{self.endpoint}/computervision/retrieval:vectorizeImage" + f"?overload=stream&model-version={self.model_version}&api-version=2024-02-01" + ) + req = urllib.request.Request(url, data=image_bytes, method="POST") + req.add_header("Ocp-Apim-Subscription-Key", self.key) + req.add_header("Content-Type", "image/jpeg") + try: + with urllib.request.urlopen(req) as resp: + data = json.loads(resp.read().decode()) + return data["vector"] + except urllib.error.HTTPError as e: + try: + body = e.fp.read().decode() if e.fp else "(no body)" + except Exception: + body = "(could not read body)" + logger.error( + "Azure Vision vectorizeImage failed: HTTP %s %s. %s", + e.code, + e.reason, + body, + exc_info=False, + ) + raise RuntimeError( + f"Azure Vision vectorizeImage failed: HTTP {e.code} {e.reason}. {body}" + ) from e + + def encode_images(self, images: List[np.ndarray]) -> np.ndarray: + import io + from PIL import Image + out = [] + for im in images: + pil = Image.fromarray((np.clip(im, 0, 1) * 255).astype(np.uint8)) + buf = io.BytesIO() + pil.save(buf, format="JPEG") + vec = self._vectorize_image_bytes(buf.getvalue()) + out.append(vec) + return np.array(out, dtype=np.float32) + + def encode_image(self, image: np.ndarray) -> np.ndarray: + vecs = self.encode_images([image]) + return vecs[0] + + +class ImageEmbedder: + """Encode images to fixed-size vectors for vector search.""" + + def __init__( + self, + model_name: str = "openai/clip-vit-base-patch32", + device: str = "cpu", + ): + self.model_name = model_name + self.device = device + self._model = None + self._processor = None + + def _load(self) -> None: + if self._model is not None: + return + try: + from transformers import CLIPModel, CLIPProcessor + except ImportError as e: + raise ImportError( + "transformers and torch required for CLIP. " + "Install with: pip install transformers torch" + ) from e + self._processor = CLIPProcessor.from_pretrained(self.model_name) + self._model = CLIPModel.from_pretrained(self.model_name) + self._model.to(self.device) + self._model.eval() + + @property + def dimension(self) -> int: + self._load() + return self._model.config.projection_dim + + def encode_images(self, images: List[np.ndarray]) -> np.ndarray: + """ + images: list of HWC float32 [0,1] RGB arrays (e.g. from dng_to_rgb). + Returns (N, dim) float32 numpy. + """ + import torch + from PIL import Image + self._load() + # CLIPProcessor expects PIL Images + pil_list = [ + Image.fromarray((np.clip(im, 0, 1) * 255).astype(np.uint8)) + for im in images + ] + inputs = self._processor(images=pil_list, return_tensors="pt", padding=True) + inputs = {k: v.to(self.device) for k, v in inputs.items()} + with torch.no_grad(): + out = self._model.get_image_features(**inputs) + # Newer transformers return BaseModelOutputWithPooling; use pooled tensor + t = getattr(out, "pooler_output", None) if hasattr(out, "pooler_output") else None + if t is None and hasattr(out, "last_hidden_state"): + t = out.last_hidden_state[:, 0] + elif t is None: + t = out + return t.detach().cpu().float().numpy() + + def encode_image(self, image: np.ndarray) -> np.ndarray: + """Single image -> (dim,) vector.""" + vecs = self.encode_images([image]) + return vecs[0] + + +def _default_device() -> str: + import torch + if torch.cuda.is_available(): + return "cuda" + if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available() and torch.backends.mps.is_built(): + return "mps" # Mac GPU (Apple Silicon) + return "cpu" + + +def get_embedder(): + """Return Azure Vision embedder if configured and available in region; else local CLIP.""" + s = get_settings() + if s.azure_vision_configured(): + try: + emb = AzureVisionEmbedder( + endpoint=s.azure_vision_endpoint, + key=s.azure_vision_key, + model_version=s.azure_vision_model_version or "2023-04-15", + ) + _ = emb.dimension # one call to verify region supports the API + logger.info( + "Using Azure Vision embedder (endpoint=%s, model_version=%s)", + s.azure_vision_endpoint, + s.azure_vision_model_version or "2023-04-15", + ) + return emb + except RuntimeError as e: + err = str(e) + if "not enabled in this region" in err or "InvalidRequest" in err: + logger.warning( + "Azure Vision retrieval/vectorize not available (region/InvalidRequest). " + "Falling back to local CLIP (Mac MPS/CUDA/CPU). Error: %s", + err, + ) + return ImageEmbedder( + model_name=s.embedding_model, + device=_default_device(), + ) + logger.error("Azure Vision embedder failed: %s", err) + raise + logger.info( + "Azure Vision not configured; using local CLIP (model=%s)", + s.embedding_model, + ) + return ImageEmbedder( + model_name=s.embedding_model, + device=_default_device(), + ) diff --git a/photo_editor/images/__init__.py b/photo_editor/images/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..269c40d4dc8b08c8cc5bdc4562e07157b7a9fd27 --- /dev/null +++ b/photo_editor/images/__init__.py @@ -0,0 +1,13 @@ +from .apply_recipe import apply_recipe, match_to_reference +from .dng_to_rgb import dng_to_rgb +from .apply_recipe_linear import apply_recipe_linear +from .image_stats import compute_image_stats, rerank_score + +__all__ = [ + "apply_recipe", + "dng_to_rgb", + "apply_recipe_linear", + "match_to_reference", + "compute_image_stats", + "rerank_score", +] diff --git a/photo_editor/images/__pycache__/__init__.cpython-313.pyc b/photo_editor/images/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..64a933e1513574c92f4ea80aa5f5b209bc3e18c4 Binary files /dev/null and b/photo_editor/images/__pycache__/__init__.cpython-313.pyc differ diff --git a/photo_editor/images/__pycache__/apply_recipe.cpython-313.pyc b/photo_editor/images/__pycache__/apply_recipe.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9fadf978fcec252a265acff16263208cb8379d47 Binary files /dev/null and b/photo_editor/images/__pycache__/apply_recipe.cpython-313.pyc differ diff --git a/photo_editor/images/__pycache__/apply_recipe_linear.cpython-313.pyc b/photo_editor/images/__pycache__/apply_recipe_linear.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5323e03f63ffa7c6d0a7c8b7e1298e00efdba626 Binary files /dev/null and b/photo_editor/images/__pycache__/apply_recipe_linear.cpython-313.pyc differ diff --git a/photo_editor/images/__pycache__/dng_to_rgb.cpython-313.pyc b/photo_editor/images/__pycache__/dng_to_rgb.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9da2bcd1e6b0852f5e5ec9a95bda0650c29b2389 Binary files /dev/null and b/photo_editor/images/__pycache__/dng_to_rgb.cpython-313.pyc differ diff --git a/photo_editor/images/__pycache__/estimate_current_recipe.cpython-313.pyc b/photo_editor/images/__pycache__/estimate_current_recipe.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5048ee85a7f609369c8a253cb2d436039f078165 Binary files /dev/null and b/photo_editor/images/__pycache__/estimate_current_recipe.cpython-313.pyc differ diff --git a/photo_editor/images/__pycache__/image_stats.cpython-313.pyc b/photo_editor/images/__pycache__/image_stats.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5e36d9a5cdd3a8f05582630cb243d8196a7e8f94 Binary files /dev/null and b/photo_editor/images/__pycache__/image_stats.cpython-313.pyc differ diff --git a/photo_editor/images/apply_recipe.py b/photo_editor/images/apply_recipe.py new file mode 100644 index 0000000000000000000000000000000000000000..3bee7fd7fa0bb138aa022b11313cc5eec77e0a29 --- /dev/null +++ b/photo_editor/images/apply_recipe.py @@ -0,0 +1,169 @@ +""" +Apply EditRecipe (global sliders) to an RGB image. +Uses scikit-image for tone curves (exposure, contrast, black/white point) and +numpy for shadows/highlights, white balance, saturation. Optional histogram +matching to a reference image (e.g. Expert A TIF) for close visual match. +Input/output: float32 RGB in [0, 1], HWC. +""" +import numpy as np +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from photo_editor.lrcat.schema import EditRecipe + +# White balance: neutral daylight (no warm/cool shift). Don't apply if within epsilon. +TEMP_NEUTRAL_K = 5500.0 +TEMP_MIN_K = 2000.0 +TEMP_MAX_K = 12000.0 +TEMP_EPSILON_K = 10.0 + +# Clamp exposure (EV) so 2.0**ev doesn't overflow (gamma in ~1e-3 to 1e3). +EV_MIN = -10.0 +EV_MAX = 10.0 + + +def _luminance(rgb: np.ndarray) -> np.ndarray: + """Rec. 709 luma.""" + return (0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2]).astype(np.float32) + + +def apply_recipe(image: np.ndarray, recipe: "EditRecipe") -> np.ndarray: + """ + Apply expert edit recipe. image: float32 [0,1] HWC RGB. + Uses skimage for exposure/contrast/black/white; numpy for shadows/highlights/WB/saturation. + """ + from skimage.exposure import adjust_gamma, adjust_sigmoid, rescale_intensity + + out = np.clip(image.astype(np.float32), 0.0, 1.0).copy() + + # 1) Exposure (EV) via gamma: 2^EV ~ brightness. gamma < 1 brightens. + try: + ev = float(getattr(recipe, "exposure", 0.0) or 0.0) + except (TypeError, ValueError): + ev = 0.0 + if ev != 0: + ev = max(EV_MIN, min(EV_MAX, ev)) + gamma = 2.0 ** (-ev) + out = adjust_gamma(out, gamma=gamma) + out = np.clip(out.astype(np.float32), 0.0, 1.0) + + # 1b) Brightness (LR 0-100). Apply as offset scaled to [0,1]. + bright = getattr(recipe, "brightness", 0.0) or 0.0 + if bright != 0: + offset = float(bright) / 100.0 * 0.2 + out = np.clip(out + offset, 0.0, 1.0) + + # 2) Contrast via sigmoid (S-curve). gain > 1 = more contrast. + c = getattr(recipe, "contrast", 0.0) or 0.0 + if c != 0: + gain = 1.0 + float(c) / 100.0 + # cutoff 0.5 = midtone; increase gain for stronger S-curve + out = adjust_sigmoid(out, cutoff=0.5, gain=gain) + out = np.clip(out.astype(np.float32), 0.0, 1.0) + + # 3) Blacks / Whites: rescale intensity range (LR ParametricDarks/Lights). + blk = getattr(recipe, "blacks", 0.0) or 0.0 + wht = getattr(recipe, "whites", 0.0) or 0.0 + if blk != 0 or wht != 0: + # in_range: shrink input range -> darker blacks / brighter whites + in_low, in_high = 0.0, 1.0 + in_low += float(blk) / 100.0 * 0.1 + in_high -= float(wht) / 100.0 * 0.05 + in_high = max(in_high, in_low + 0.01) + out = rescale_intensity(out, in_range=(in_low, in_high), out_range=(0.0, 1.0)) + out = np.clip(out.astype(np.float32), 0.0, 1.0) + + # 4) Shadows: lift dark tones (luminance-weighted). + sh = getattr(recipe, "shadows", 0.0) or 0.0 + if sh != 0: + L = _luminance(out) + weight = 1.0 - np.clip(L, 0, 1) ** 0.5 + lift = float(sh) / 100.0 * 0.4 + out = out + lift * np.broadcast_to(weight[..., np.newaxis], out.shape) + out = np.clip(out, 0.0, 1.0) + + # 5) Highlights: pull down brights (luminance-weighted). + hi = getattr(recipe, "highlights", 0.0) or 0.0 + if hi != 0: + L = _luminance(out) + weight = np.clip(L, 0, 1) ** 1.5 + amount = -float(hi) / 100.0 * 0.45 + out = out + amount * np.broadcast_to(weight[..., np.newaxis], out.shape) + out = np.clip(out, 0.0, 1.0) + + # 6) Temperature (Kelvin). Only apply if recipe differs from neutral. + # Lower K = warmer (more red); higher K = cooler (more blue). So t > 0 when temp < neutral = more red. + temp = getattr(recipe, "temperature", TEMP_NEUTRAL_K) or TEMP_NEUTRAL_K + if TEMP_MIN_K < temp < TEMP_MAX_K and abs(temp - TEMP_NEUTRAL_K) > TEMP_EPSILON_K: + t = (TEMP_NEUTRAL_K - float(temp)) / TEMP_NEUTRAL_K # warm (low K) -> t > 0 -> more red + out[..., 0] = np.clip(out[..., 0] * (1.0 + t * 0.5), 0, 1) + out[..., 2] = np.clip(out[..., 2] * (1.0 - t * 0.5), 0, 1) + + # 7) Tint (green-magenta). + tint = getattr(recipe, "tint", 0.0) or 0.0 + if tint != 0: + t = float(tint) / 100.0 + out[..., 0] = np.clip(out[..., 0] * (1.0 + t * 0.2), 0, 1) + out[..., 1] = np.clip(out[..., 1] * (1.0 - t * 0.25), 0, 1) + out[..., 2] = np.clip(out[..., 2] * (1.0 + t * 0.2), 0, 1) + + # 8) Saturation (luminance-preserving). + sat = getattr(recipe, "saturation", 0.0) or 0.0 + vib = getattr(recipe, "vibrance", 0.0) or 0.0 + s = (float(sat) + float(vib)) / 100.0 + if s != 0: + L = _luminance(out) + L = np.broadcast_to(L[..., np.newaxis], out.shape) + out = L + (1.0 + s) * (out - L) + out = np.clip(out, 0.0, 1.0) + + # 9) Tone curve (parametric 4-point). Remap [0,1] via piecewise linear. + tc = getattr(recipe, "tone_curve", None) + if tc and len(tc) >= 4: + # tc = [v0,v1,v2,v3] LR style; map 0->v0/255, 0.33->v1/255, 0.67->v2/255, 1->v3/255 + xs = np.array([0.0, 1.0 / 3.0, 2.0 / 3.0, 1.0], dtype=np.float32) + ys = np.clip(np.array(tc[:4], dtype=np.float32) / 255.0, 0.0, 1.0) + if np.any(ys != np.array([0, 0, 1, 1])): # not identity + L = _luminance(out) + L_flat = np.clip(L.ravel(), 0, 1) + mapped = np.interp(L_flat, xs, ys).reshape(L.shape) + out = out + np.broadcast_to((mapped - L)[..., np.newaxis], out.shape) + out = np.clip(out, 0.0, 1.0) + + # 10) Sharpening (unsharp mask). + sd = getattr(recipe, "sharpen_detail", 0.0) or 0.0 + sr = getattr(recipe, "sharpen_radius", 0.0) or 0.0 + if sd != 0 and sr >= 0: + try: + from skimage.filters import unsharp_mask + radius = max(0.5, float(sr)) if sr > 0 else 1.0 + amount = float(sd) / 100.0 * 0.5 + out = unsharp_mask(out, radius=radius, amount=amount, channel_axis=-1) + out = np.clip(out.astype(np.float32), 0.0, 1.0) + except Exception: + pass + + return out.astype(np.float32) + + +def match_to_reference(image: np.ndarray, reference: np.ndarray) -> np.ndarray: + """ + Match image's per-channel histograms to reference (e.g. Expert A TIF). + image, reference: float32 [0,1] HWC. Reference is resized to image shape if needed. + """ + from skimage.exposure import match_histograms + from skimage.transform import resize + + ref = np.clip(reference.astype(np.float32), 0.0, 1.0) + if ref.shape[:2] != image.shape[:2]: + ref = resize( + ref, + (image.shape[0], image.shape[1]), + order=1, + anti_aliasing=True, + preserve_range=True, + ).astype(np.float32) + ref = np.clip(ref, 0.0, 1.0) + + matched = match_histograms(image, ref, channel_axis=-1) + return np.clip(matched.astype(np.float32), 0.0, 1.0) diff --git a/photo_editor/images/apply_recipe_linear.py b/photo_editor/images/apply_recipe_linear.py new file mode 100644 index 0000000000000000000000000000000000000000..d1daccd0387e6e4b865b860d2bbf0e0ec5fbd49b --- /dev/null +++ b/photo_editor/images/apply_recipe_linear.py @@ -0,0 +1,101 @@ +""" +Alternative apply: linear exposure, simple temp/brightness/shadows/saturation. +Baseline (e.g. Original) used only for brightness/shadows/contrast/saturation deltas; +temperature and tint use fixed neutral (camera WB from rawpy). Input/output: float32 [0,1] HWC. +""" +import numpy as np +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from photo_editor.lrcat.schema import EditRecipe + +TEMP_NEUTRAL = 5229.0 # fixed neutral for WB (baseline not used for temp/tint; image is at camera WB) +EV_MIN, EV_MAX = -10.0, 10.0 + + +def _luminance(rgb: np.ndarray) -> np.ndarray: + return (0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2]).astype(np.float32) + + +def _val(r: "EditRecipe", key: str, default: float = 0.0) -> float: + v = getattr(r, key, default) + if v is None: + return default + try: + return float(v) + except (TypeError, ValueError): + return default + + +def apply_recipe_linear( + image: np.ndarray, + recipe: "EditRecipe", + baseline_recipe: Optional["EditRecipe"] = None, +) -> np.ndarray: + """ + Apply recipe. Baseline (e.g. Original) is used only for brightness/shadows/ + contrast/saturation deltas. Temperature and tint use fixed neutral (image + is at camera WB from rawpy). + """ + out = np.clip(image.astype(np.float32), 0.0, 1.0).copy() + base = baseline_recipe + + # 1) Temperature: fixed neutral (no baseline); higher K = cooler = more blue + temp = _val(recipe, "temperature", TEMP_NEUTRAL) or TEMP_NEUTRAL + temp_factor = temp / TEMP_NEUTRAL + out[..., 0] *= 1.0 - (temp_factor - 1.0) * 0.2 # red: decrease when cooler + out[..., 2] *= 1.0 + (temp_factor - 1.0) * 0.8 # blue: increase when cooler + + # 1b) Tint: absolute (no baseline) + tint = _val(recipe, "tint", 0.0) / 100.0 + if tint != 0: + out[..., 0] *= 1.0 + tint * 0.2 + out[..., 1] *= 1.0 - tint * 0.25 + out[..., 2] *= 1.0 + tint * 0.2 + out = np.clip(out, 0.0, 1.0) + + # 2) Exposure: linear multiplier 2^ev (no delta when baseline; recipe is absolute or already baked) + try: + ev = float(getattr(recipe, "exposure", 0.0) or 0.0) + except (TypeError, ValueError): + ev = 0.0 + ev = max(EV_MIN, min(EV_MAX, ev)) + out *= 2.0 ** ev + + # 3) Brightness + bright = _val(recipe, "brightness", 0.0) / 100.0 + if base: + bright -= _val(base, "brightness", 0.0) / 100.0 + if bright != 0: + out += bright + out = np.clip(out, 0.0, 1.0) + + # 4) Shadows (delta when baseline) + sh = _val(recipe, "shadows", 0.0) / 100.0 + if base: + sh -= _val(base, "shadows", 0.0) / 100.0 + if sh != 0: + mask = np.clip(1.0 - np.mean(out, axis=-1) * 2.0, 0, 1) + lift = (sh * 0.2) * mask + out += np.broadcast_to(lift[..., np.newaxis], out.shape) + out = np.clip(out, 0.0, 1.0) + + # 5) Contrast (delta when baseline) + c = _val(recipe, "contrast", 0.0) / 100.0 + if base: + c -= _val(base, "contrast", 0.0) / 100.0 + if c != 0: + out = (out - 0.5) * (1.0 + c) + 0.5 + out = np.clip(out, 0.0, 1.0) + + # 6) Saturation (delta when baseline) + sat = _val(recipe, "saturation", 0.0) / 100.0 + _val(recipe, "vibrance", 0.0) / 100.0 + if base: + sat -= _val(base, "saturation", 0.0) / 100.0 + _val(base, "vibrance", 0.0) / 100.0 + if sat != 0: + L = _luminance(out) + L = np.broadcast_to(L[..., np.newaxis], out.shape) + out = L + (1.0 + sat) * (out - L) + out = np.clip(out, 0.0, 1.0) + + return out.astype(np.float32) diff --git a/photo_editor/images/dng_to_rgb.py b/photo_editor/images/dng_to_rgb.py new file mode 100644 index 0000000000000000000000000000000000000000..c650352d8b6c23bdb067dab2eeef0cecd08a001f --- /dev/null +++ b/photo_editor/images/dng_to_rgb.py @@ -0,0 +1,103 @@ +""" +DNG to RGB using rawpy (default/neutral development). +Output is suitable for embedding (e.g. CLIP expects RGB images). + +Normalized development: use_camera_wb + exp_shift/bright from recipe so +exposure and brightness are applied at raw develop time (closer to LR). +""" +from pathlib import Path +from typing import TYPE_CHECKING, Union + +import numpy as np + +try: + import rawpy +except ImportError: + rawpy = None + +if TYPE_CHECKING: + from photo_editor.lrcat.schema import EditRecipe + +# rawpy exp_shift usable range ~0.25 (2-stop darken) to 8.0 (3-stop lighter) +_EXP_SHIFT_MIN, _EXP_SHIFT_MAX = 0.25, 8.0 + + +def dng_to_rgb( + path: Union[str, Path], + output_size: tuple = (224, 224), +) -> np.ndarray: + """ + Load DNG and develop to sRGB with default settings; resize to output_size. + Returns float32 array (0..1) HWC RGB for embedding models. + """ + if rawpy is None: + raise ImportError("rawpy is required for DNG support. Install with: pip install rawpy") + path = Path(path) + if not path.exists(): + raise FileNotFoundError(str(path)) + + with rawpy.imread(str(path)) as raw: + rgb = raw.postprocess( + use_camera_wb=True, + half_size=False, + no_auto_bright=True, + output_bps=16, + ) + # rgb: HWC uint16 + rgb = rgb.astype(np.float32) / 65535.0 + # Resize if needed (CLIP often uses 224x224) + if output_size and (rgb.shape[0] != output_size[0] or rgb.shape[1] != output_size[1]): + try: + from PIL import Image + pil = Image.fromarray((rgb * 255).astype(np.uint8)) + pil = pil.resize((output_size[1], output_size[0]), Image.BILINEAR) + rgb = np.array(pil).astype(np.float32) / 255.0 + except ImportError: + pass # keep original size if no PIL + return rgb + + +def dng_to_rgb_normalized( + path: Union[str, Path], + recipe: "EditRecipe", + output_size: tuple = None, +) -> np.ndarray: + """ + Develop DNG with camera white balance and bake in recipe exposure + brightness + via rawpy (exp_shift, bright). Use with apply_recipe_linear on a copy of + recipe with exposure=0, brightness=0 for the rest (temp, tint, shadows, etc.). + Returns float32 [0, 1] HWC RGB. + """ + if rawpy is None: + raise ImportError("rawpy is required for DNG support. Install with: pip install rawpy") + path = Path(path) + if not path.exists(): + raise FileNotFoundError(str(path)) + + ev = float(getattr(recipe, "exposure", 0.0) or 0.0) + ev = max(-2.0, min(3.0, ev)) # keep 2**ev in rawpy range + exp_shift = 2.0 ** ev + exp_shift = max(_EXP_SHIFT_MIN, min(_EXP_SHIFT_MAX, exp_shift)) + + bright_val = float(getattr(recipe, "brightness", 0.0) or 0.0) + bright = 1.0 + (bright_val / 100.0) + + with rawpy.imread(str(path)) as raw: + rgb = raw.postprocess( + use_camera_wb=True, + half_size=False, + no_auto_bright=True, + output_bps=16, + exp_shift=exp_shift, + bright=bright, + ) + rgb = rgb.astype(np.float32) / 65535.0 + if output_size and (rgb.shape[0] != output_size[0] or rgb.shape[1] != output_size[1]): + try: + from PIL import Image + pil = Image.fromarray((np.clip(rgb, 0, 1) * 255).astype(np.uint8)) + pil = pil.resize((output_size[1], output_size[0]), Image.BILINEAR) + rgb = np.array(pil).astype(np.float32) / 255.0 + except ImportError: + pass + return rgb diff --git a/photo_editor/images/estimate_current_recipe.py b/photo_editor/images/estimate_current_recipe.py new file mode 100644 index 0000000000000000000000000000000000000000..57975cac514b5b73fa1f48ba2fe5bba1943bc67b --- /dev/null +++ b/photo_editor/images/estimate_current_recipe.py @@ -0,0 +1,124 @@ +""" +Heuristically estimate \"current\" global edit parameters from an image. + +These are NOT true Lightroom sliders; they are best-effort values in the same +units as our pipeline so we can show Current vs Suggested vs Delta. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Dict, Tuple + +import numpy as np +from PIL import Image, UnidentifiedImageError + +# Optional HEIC/HEIF decoding support for Pillow. +try: + import pillow_heif + + pillow_heif.register_heif_opener() +except Exception: + pillow_heif = None + +from photo_editor.images.dng_to_rgb import dng_to_rgb +from photo_editor.images.image_stats import compute_image_stats, luminance + + +def _load_rgb_full(path: Path) -> np.ndarray: + path = Path(path) + if path.suffix.lower() == ".dng": + return dng_to_rgb(path, output_size=None) + try: + img = Image.open(path).convert("RGB") + except UnidentifiedImageError as e: + suffix = path.suffix.lower() + if suffix in {".heic", ".heif"}: + raise RuntimeError( + "HEIC/HEIF decoding is unavailable in this environment. " + "Install pillow-heif (`pip install pillow-heif`) and restart Streamlit." + ) from e + raise + return np.asarray(img, dtype=np.float32) / 255.0 + + +def _channel_means_midtones(rgb: np.ndarray) -> Tuple[float, float, float]: + rgb = np.clip(rgb.astype(np.float32), 0.0, 1.0) + lum = luminance(rgb) + mask = (lum >= 0.2) & (lum <= 0.85) + if not np.any(mask): + m = rgb.reshape(-1, 3).mean(axis=0) + return float(m[0]), float(m[1]), float(m[2]) + m = rgb[mask].reshape(-1, 3).mean(axis=0) + return float(m[0]), float(m[1]), float(m[2]) + + +def estimate_current_parameters(image_path: Path) -> Dict[str, float]: + """ + Return a dict with keys: + exposure, contrast, highlights, shadows, whites, blacks, + temperature, tint, vibrance, saturation + based on simple brightness / saturation heuristics. + """ + rgb = _load_rgb_full(image_path) + stats = compute_image_stats(rgb) + + p10 = float(stats["brightness_p10"]) + p50 = float(stats["brightness_p50"]) + p90 = float(stats["brightness_p90"]) + sat_mean = float(stats["saturation_mean"]) # 0..100 + + # Exposure: map median luminance to ~0.35 + target_mid = 0.35 + ev = np.log2(max(1e-6, target_mid) / max(1e-6, p50)) + ev = float(np.clip(ev, -2.0, 2.0)) + + # Contrast: based on percentile spread + spread = max(1e-6, p90 - p10) + target_spread = 0.6 + contrast = (spread / target_spread - 1.0) * 50.0 + contrast = float(np.clip(contrast, -50.0, 50.0)) + + # Temperature / tint: from midtone channel balance + r_m, g_m, b_m = _channel_means_midtones(rgb) + rb_sum = max(1e-6, r_m + b_m) + coolness = (b_m - r_m) / rb_sum # + cooler + temperature = 5500.0 * (1.0 + coolness * 1.5) + temperature = float(np.clip(temperature, 2500.0, 12000.0)) + + green_ref = 0.5 * (r_m + b_m) + denom = max(1e-6, g_m + green_ref) + green_bias = (g_m - green_ref) / denom # + greener + tint = float(np.clip(green_bias * 120.0, -50.0, 50.0)) + + # Saturation / vibrance + saturation = (sat_mean - 30.0) * 2.0 + saturation = float(np.clip(saturation, -50.0, 50.0)) + vibrance = float(np.clip(saturation * 0.6, -50.0, 50.0)) + + # Highlights / shadows / whites / blacks (very rough) + shadows = (p10 - 0.15) * 200.0 + shadows = float(np.clip(shadows, -60.0, 60.0)) + + highlights = (0.85 - p90) * -180.0 + highlights = float(np.clip(highlights, -60.0, 60.0)) + + whites = (p90 - 0.9) * 250.0 + whites = float(np.clip(whites, -60.0, 60.0)) + + blacks = (0.1 - p10) * -250.0 + blacks = float(np.clip(blacks, -60.0, 60.0)) + + return { + "exposure": round(ev, 4), + "contrast": round(contrast, 4), + "highlights": round(highlights, 4), + "shadows": round(shadows, 4), + "whites": round(whites, 4), + "blacks": round(blacks, 4), + "temperature": round(temperature, 2), + "tint": round(tint, 4), + "vibrance": round(vibrance, 4), + "saturation": round(saturation, 4), + } + diff --git a/photo_editor/images/image_stats.py b/photo_editor/images/image_stats.py new file mode 100644 index 0000000000000000000000000000000000000000..0fea345a86e013829379d3d286a712fc8b36c806 --- /dev/null +++ b/photo_editor/images/image_stats.py @@ -0,0 +1,92 @@ +""" +Lighting and color statistics for reranking (brightness percentiles, +highlight/shadow clipping, saturation). Used at index time and query time. +Input: RGB float [0, 1] HWC. +""" +from typing import Any, Dict + +import numpy as np + +# Luminance weights (Rec. 709) +_LUM_R, _LUM_G, _LUM_B = 0.2126, 0.7152, 0.0722 + + +def luminance(rgb: np.ndarray) -> np.ndarray: + """Return luminance shape (H,W) from RGB (H,W,3) float [0,1].""" + r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2] + return (_LUM_R * r + _LUM_G * g + _LUM_B * b).astype(np.float32) + + +def saturation_per_pixel(rgb: np.ndarray) -> np.ndarray: + """Per-pixel saturation: (max(R,G,B)-min)/max when max>0 else 0. Shape (H,W).""" + mx = np.max(rgb, axis=-1) + mn = np.min(rgb, axis=-1) + sat = np.zeros_like(mx, dtype=np.float32) + # Use np.divide with where= to avoid warnings when mx == 0. + np.divide(mx - mn, mx, out=sat, where=mx > 1e-6) + return sat.astype(np.float32) + + +def compute_image_stats(rgb: np.ndarray) -> Dict[str, float]: + """ + Compute lighting and color statistics for reranking. + rgb: float32/64 [0, 1] HWC. + Returns dict: brightness_p10, brightness_p50, brightness_p90, + highlight_clip, shadow_clip, saturation_mean, saturation_std. + """ + rgb = np.clip(rgb.astype(np.float64), 0.0, 1.0) + lum = luminance(rgb) + sat = saturation_per_pixel(rgb) + + # Brightness percentiles (0–1) + flat_lum = lum.ravel() + p10 = float(np.percentile(flat_lum, 10)) + p50 = float(np.percentile(flat_lum, 50)) + p90 = float(np.percentile(flat_lum, 90)) + + # Highlight clipping: % of pixels with luminance or max(R,G,B) >= 0.99 + high = (lum >= 0.99) | (np.max(rgb, axis=-1) >= 0.99) + highlight_clip = float(np.mean(high) * 100.0) + + # Shadow clipping: % with luminance or min(R,G,B) <= 0.01 + low = (lum <= 0.01) | (np.min(rgb, axis=-1) <= 0.01) + shadow_clip = float(np.mean(low) * 100.0) + + # Saturation + flat_sat = sat.ravel() + saturation_mean = float(np.mean(flat_sat) * 100.0) + saturation_std = float(np.std(flat_sat) * 100.0) + + return { + "brightness_p10": round(p10, 6), + "brightness_p50": round(p50, 6), + "brightness_p90": round(p90, 6), + "highlight_clip": round(highlight_clip, 4), + "shadow_clip": round(shadow_clip, 4), + "saturation_mean": round(saturation_mean, 4), + "saturation_std": round(saturation_std, 4), + } + + +def rerank_score(user_stats: Dict[str, float], candidate_stats: Dict[str, float]) -> float: + """ + Higher = better alignment. Uses L1 distance on normalized stats, then 1/(1+d). + Weights: brightness and clipping matter most for editing intent. + """ + keys = [ + "brightness_p10", "brightness_p50", "brightness_p90", + "highlight_clip", "shadow_clip", + "saturation_mean", "saturation_std", + ] + # Scale to similar magnitude (percentiles 0–1, clip/sat in 0–100) + scales = { + "brightness_p10": 1.0, "brightness_p50": 1.0, "brightness_p90": 1.0, + "highlight_clip": 0.01, "shadow_clip": 0.01, + "saturation_mean": 0.01, "saturation_std": 0.01, + } + d = 0.0 + for k in keys: + u = user_stats.get(k, 0.0) + c = candidate_stats.get(k, 0.0) + d += abs(u - c) * scales[k] + return 1.0 / (1.0 + d) diff --git a/photo_editor/lrcat/__init__.py b/photo_editor/lrcat/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5cec048358646cff1e68bb544fe57b8f9665a097 --- /dev/null +++ b/photo_editor/lrcat/__init__.py @@ -0,0 +1,13 @@ +from .schema import EditRecipe +from .extract import ( + extract_expert_a_recipes, + get_recipe_for_image, + get_recipe_for_image_and_copy, +) + +__all__ = [ + "EditRecipe", + "extract_expert_a_recipes", + "get_recipe_for_image", + "get_recipe_for_image_and_copy", +] diff --git a/photo_editor/lrcat/__pycache__/__init__.cpython-313.pyc b/photo_editor/lrcat/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5835ea02572333b9f47830a15c4c36727ed3b40c Binary files /dev/null and b/photo_editor/lrcat/__pycache__/__init__.cpython-313.pyc differ diff --git a/photo_editor/lrcat/__pycache__/extract.cpython-313.pyc b/photo_editor/lrcat/__pycache__/extract.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1dca4d64039baec270a672f809dc3d0268534989 Binary files /dev/null and b/photo_editor/lrcat/__pycache__/extract.cpython-313.pyc differ diff --git a/photo_editor/lrcat/__pycache__/schema.cpython-313.pyc b/photo_editor/lrcat/__pycache__/schema.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..138787606736a74e05b39ff3d32aaa30a378a6c4 Binary files /dev/null and b/photo_editor/lrcat/__pycache__/schema.cpython-313.pyc differ diff --git a/photo_editor/lrcat/extract.py b/photo_editor/lrcat/extract.py new file mode 100644 index 0000000000000000000000000000000000000000..265bf2c74da6832c00f6a1cf830e891b010baf5a --- /dev/null +++ b/photo_editor/lrcat/extract.py @@ -0,0 +1,190 @@ +""" +Extract Expert A develop settings from fivek.lrcat. +Expert A = Copy 8 in this catalog (configurable via EXPERT_A_COPY_NAME). +""" +import re +import sqlite3 +from pathlib import Path +from typing import Any, Dict, Optional + +from photo_editor.lrcat.schema import EditRecipe + +# Expert A = Copy 8 in this catalog (try Copy 8 if Copy 1 had no develop data) +EXPERT_A_COPY_NAME = "Copy 3" + + +def _parse_lr_develop_text(text: str) -> Dict[str, Any]: + """ + Parse Lightroom develop settings string (Lua-like s = { key = value }). + Extracts numeric key = value and ToneCurve = { n1, n2, n3, n4 }. + """ + out: Dict[str, Any] = {} + # Match key = number (int or float) + for m in re.finditer(r"(\w+)\s*=\s*([+-]?\d+(?:\.\d+)?)", text): + key, val = m.group(1), m.group(2) + try: + if "." in val: + out[key] = float(val) + else: + out[key] = float(int(val)) + except ValueError: + continue + # ToneCurve = { 0, 0, 255, 255 } + tone_curve_m = re.search(r"ToneCurve\s*=\s*\{\s*([^}]+)\}", text) + if tone_curve_m: + parts = re.findall(r"[+-]?\d+(?:\.\d+)?", tone_curve_m.group(1)) + if len(parts) >= 4: + try: + out["ToneCurve"] = [float(x) for x in parts[:4]] + except ValueError: + pass + return out + + +def _lr_to_recipe(lr: Dict[str, Any]) -> EditRecipe: + """Map Lightroom keys to our EditRecipe schema. Use Custom* when present. + Highlights/Shadows: use ParametricHighlights/ParametricShadows (tone sliders); + HighlightRecovery is a different control (recovery of blown highlights). + Sign: LR ParametricHighlights negative = darken highlights; our apply uses + positive 'highlights' = darken, so we store -ParametricHighlights. + """ + temp = lr.get("Temperature", lr.get("CustomTemperature", 0.0)) + tint = lr.get("Tint", lr.get("CustomTint", 0.0)) + tc = lr.get("ToneCurve") + # ParametricHighlights = tone slider; negate so our apply (positive = darken) matches LR. + parametric_high = float(lr.get("ParametricHighlights", 0)) + shadows_val = float(lr.get("ParametricShadows", lr.get("Shadows", 0))) + return EditRecipe( + exposure=float(lr.get("Exposure", 0)), + brightness=float(lr.get("Brightness", 0)), + contrast=float(lr.get("Contrast", 0)), + highlights=float(-parametric_high), # LR neg = darken; we store so positive = darken + shadows=float(shadows_val), + whites=float(lr.get("ParametricLights", 0)), + blacks=float(lr.get("ParametricDarks", 0)), + temperature=float(temp), + tint=float(tint), + vibrance=float(lr.get("Vibrance", 0)), + saturation=float(lr.get("Saturation", 0)), + tone_curve=tc if isinstance(tc, (list, tuple)) and len(tc) >= 4 else None, + sharpen_detail=float(lr.get("SharpenDetail", 0)), + sharpen_radius=float(lr.get("SharpenRadius", 0)), + sharpen_edge_masking=float(lr.get("SharpenEdgeMasking", 0)), + perspective_horizontal=float(lr.get("PerspectiveHorizontal", 0)), + perspective_vertical=float(lr.get("PerspectiveVertical", 0)), + perspective_rotate=float(lr.get("PerspectiveRotate", 0)), + perspective_scale=float(lr.get("PerspectiveScale", 100)), + lens_distortion=float(lr.get("LensManualDistortionAmount", 0)), + ) + + +def extract_expert_a_recipes(lrcat_path: Path) -> Dict[str, EditRecipe]: + """ + Load catalog and return dict image_id -> EditRecipe for Expert A (Copy 8). + image_id = baseName from AgLibraryFile (e.g. a0001-jmac_DSC1459). + """ + conn = sqlite3.connect(str(lrcat_path)) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + # Expert A = Copy 8. Get (image id_local, rootFile) for that copy only. + cur.execute(""" + SELECT i.id_local AS image_id, i.rootFile + FROM Adobe_images i + WHERE i.copyName = ? + """, (EXPERT_A_COPY_NAME,)) + copy1_rows = {r["image_id"]: r["rootFile"] for r in cur.fetchall()} + + # rootFile -> baseName (image id string) + cur.execute("SELECT id_local, baseName FROM AgLibraryFile") + file_id_to_base = {r["id_local"]: r["baseName"] for r in cur.fetchall()} + + # Develop settings: image id_local -> text (prefer first non-empty per image) + cur.execute(""" + SELECT image, text FROM Adobe_imageDevelopSettings + WHERE text IS NOT NULL AND text != '' + """) + image_to_text: Dict[int, str] = {} + for r in cur.fetchall(): + img_id, text = r["image"], (r["text"] or "").strip() + if img_id not in image_to_text and text: + image_to_text[img_id] = r["text"] + + conn.close() + + result: Dict[str, EditRecipe] = {} + for img_id, root_file_id in copy1_rows.items(): + base_name = file_id_to_base.get(root_file_id) + if not base_name: + continue + text = image_to_text.get(img_id) + if not text: + continue + lr = _parse_lr_develop_text(text) + result[base_name] = _lr_to_recipe(lr) + return result + + +def get_recipe_for_image( + image_id: str, + recipes: Dict[str, EditRecipe], +) -> Optional[EditRecipe]: + """Return recipe for image_id if present.""" + return recipes.get(image_id) + + +def get_recipe_for_image_and_copy( + lrcat_path: Path, + image_id: str, + copy_name: Optional[str], +) -> Optional[EditRecipe]: + """ + Get develop settings for one image and one copy (e.g. "Copy 1", "Copy 8"). + Pass copy_name=None to get the Original (as-shot) recipe (copyName IS NULL). + Returns EditRecipe or None if that copy has no (or no non-empty) develop settings. + """ + conn = sqlite3.connect(str(lrcat_path)) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + cur.execute("SELECT id_local FROM AgLibraryFile WHERE baseName = ?", (image_id,)) + row = cur.fetchone() + if not row: + conn.close() + return None + file_id = row["id_local"] + + if copy_name is None: + cur.execute( + "SELECT id_local FROM Adobe_images WHERE rootFile = ? AND copyName IS NULL", + (file_id,), + ) + else: + cur.execute( + "SELECT id_local FROM Adobe_images WHERE rootFile = ? AND copyName = ?", + (file_id, copy_name), + ) + row = cur.fetchone() + if not row: + conn.close() + return None + img_id = row["id_local"] + + cur.execute("SELECT text FROM Adobe_imageDevelopSettings WHERE image = ?", (img_id,)) + dev_rows = cur.fetchall() + conn.close() + + text = "" + if dev_rows: + for dev in dev_rows: + t = (dev["text"] or "").strip() if dev["text"] is not None else "" + if t: + text = dev["text"] if dev["text"] is not None else "" + break + if not text and dev_rows: + text = dev_rows[0]["text"] or "" + + if not text.strip(): + return None + lr = _parse_lr_develop_text(text) + return _lr_to_recipe(lr) diff --git a/photo_editor/lrcat/schema.py b/photo_editor/lrcat/schema.py new file mode 100644 index 0000000000000000000000000000000000000000..38c20672b34d8329c7844e65f88f924e0be38287 --- /dev/null +++ b/photo_editor/lrcat/schema.py @@ -0,0 +1,97 @@ +""" +Edit recipe schema aligned with Lightroom develop settings we can apply in Python. +All numeric; used as payload in vector DB and for apply-edits. +""" +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +@dataclass +class EditRecipe: + """ + Expert edit parameters (from FiveK / Lightroom develop settings). + Includes all numeric sliders we can parse and apply in Python. + """ + # Tone + exposure: float = 0.0 + brightness: float = 0.0 # LR Brightness 0-100 + contrast: float = 0.0 + highlights: float = 0.0 # from -ParametricHighlights (tone slider; LR neg = darken) + shadows: float = 0.0 + whites: float = 0.0 # ParametricLights + blacks: float = 0.0 # ParametricDarks + # White balance + temperature: float = 0.0 # Kelvin; LR uses Temperature or CustomTemperature + tint: float = 0.0 + # Color + vibrance: float = 0.0 + saturation: float = 0.0 + # Tone curve (parametric): 4 points as shadows, darks, lights, highlights (0-255) + tone_curve: Optional[List[float]] = None # [v1, v2, v3, v4] or None + # Sharpening + sharpen_detail: float = 0.0 # SharpenDetail + sharpen_radius: float = 0.0 # SharpenRadius + sharpen_edge_masking: float = 0.0 # SharpenEdgeMasking + # Perspective (LR Perspective*) + perspective_horizontal: float = 0.0 + perspective_vertical: float = 0.0 + perspective_rotate: float = 0.0 + perspective_scale: float = 100.0 + # Lens + lens_distortion: float = 0.0 # LensManualDistortionAmount + + def to_dict(self) -> Dict[str, Any]: + d = { + "exposure": self.exposure, + "brightness": self.brightness, + "contrast": self.contrast, + "highlights": self.highlights, + "shadows": self.shadows, + "whites": self.whites, + "blacks": self.blacks, + "temperature": self.temperature, + "tint": self.tint, + "vibrance": self.vibrance, + "saturation": self.saturation, + "sharpen_detail": self.sharpen_detail, + "sharpen_radius": self.sharpen_radius, + "sharpen_edge_masking": self.sharpen_edge_masking, + "perspective_horizontal": self.perspective_horizontal, + "perspective_vertical": self.perspective_vertical, + "perspective_rotate": self.perspective_rotate, + "perspective_scale": self.perspective_scale, + "lens_distortion": self.lens_distortion, + } + if self.tone_curve is not None: + d["tone_curve"] = list(self.tone_curve) + return d + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "EditRecipe": + tc = d.get("tone_curve") + if isinstance(tc, (list, tuple)) and len(tc) >= 4: + tc = [float(x) for x in tc[:4]] + else: + tc = None + return cls( + exposure=float(d.get("exposure", 0)), + brightness=float(d.get("brightness", 0)), + contrast=float(d.get("contrast", 0)), + highlights=float(d.get("highlights", 0)), + shadows=float(d.get("shadows", 0)), + whites=float(d.get("whites", 0)), + blacks=float(d.get("blacks", 0)), + temperature=float(d.get("temperature", 0)), + tint=float(d.get("tint", 0)), + vibrance=float(d.get("vibrance", 0)), + saturation=float(d.get("saturation", 0)), + tone_curve=tc, + sharpen_detail=float(d.get("sharpen_detail", 0)), + sharpen_radius=float(d.get("sharpen_radius", 0)), + sharpen_edge_masking=float(d.get("sharpen_edge_masking", 0)), + perspective_horizontal=float(d.get("perspective_horizontal", 0)), + perspective_vertical=float(d.get("perspective_vertical", 0)), + perspective_rotate=float(d.get("perspective_rotate", 0)), + perspective_scale=float(d.get("perspective_scale", 100)), + lens_distortion=float(d.get("lens_distortion", 0)), + ) diff --git a/photo_editor/pipeline/__init__.py b/photo_editor/pipeline/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6e41b8d21dc2c71d6fd5828d145bc4378737c44b --- /dev/null +++ b/photo_editor/pipeline/__init__.py @@ -0,0 +1,16 @@ +""" +End-to-end pipeline: user image β†’ retrieve + rerank β†’ LLM β†’ apply edits β†’ output. +""" +from .run import ( + retrieve_similar, + get_llm_suggestions, + apply_edits, + run_pipeline, +) + +__all__ = [ + "retrieve_similar", + "get_llm_suggestions", + "apply_edits", + "run_pipeline", +] diff --git a/photo_editor/pipeline/__pycache__/__init__.cpython-313.pyc b/photo_editor/pipeline/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5a1203bdc9f191bde462fb63e0db7324c3dda6e5 Binary files /dev/null and b/photo_editor/pipeline/__pycache__/__init__.cpython-313.pyc differ diff --git a/photo_editor/pipeline/__pycache__/run.cpython-313.pyc b/photo_editor/pipeline/__pycache__/run.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5bd0c7a3f46716e5c189e998f094b2078b3429c5 Binary files /dev/null and b/photo_editor/pipeline/__pycache__/run.cpython-313.pyc differ diff --git a/photo_editor/pipeline/run.py b/photo_editor/pipeline/run.py new file mode 100644 index 0000000000000000000000000000000000000000..d44ac74f7355e3793890b8925864986eb497a40b --- /dev/null +++ b/photo_editor/pipeline/run.py @@ -0,0 +1,446 @@ +""" +Pipeline: retrieve similar experts β†’ LLM analyzes and suggests edits β†’ apply (API or local). +Flow: User image β†’ embed β†’ vector search β†’ rerank by stats β†’ top recipe β†’ LLM (image + recipe) β†’ edits β†’ apply. +""" +import base64 +import json +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Tuple + +import numpy as np +from PIL import Image, UnidentifiedImageError + +# Optional HEIC/HEIF decoding support for Pillow. +try: + import pillow_heif + + pillow_heif.register_heif_opener() +except Exception: + pillow_heif = None + +from photo_editor.config import get_settings +from photo_editor.embeddings import get_embedder +from photo_editor.images import ( + dng_to_rgb, + compute_image_stats, + rerank_score, + apply_recipe_linear, +) +from photo_editor.images.estimate_current_recipe import estimate_current_parameters +from photo_editor.lrcat.schema import EditRecipe +from photo_editor.vector_store import AzureSearchVectorStore + + +def _open_rgb_image(path: Path) -> Image.Image: + """ + Open an image with Pillow and return RGB. Provide a clear error message + when HEIC/HEIF support is missing. + """ + path = Path(path) + try: + return Image.open(path).convert("RGB") + except UnidentifiedImageError as e: + if path.suffix.lower() in {".heic", ".heif"}: + raise RuntimeError( + "HEIC/HEIF decoding is unavailable in this environment. " + "Install pillow-heif (`pip install pillow-heif`) and restart." + ) from e + raise + + +def load_image_rgb_224(path: Path) -> np.ndarray: + """Load as float32 RGB [0, 1] HWC at 224x224 (same as index).""" + path = Path(path) + if path.suffix.lower() == ".dng": + return dng_to_rgb(path, output_size=(224, 224)) + img = _open_rgb_image(path) + img = img.resize((224, 224), Image.Resampling.BILINEAR) + return np.array(img, dtype=np.float32) / 255.0 + + +def load_image_rgb_full(path: Path) -> np.ndarray: + """Load full-resolution RGB float32 [0, 1] for editing.""" + path = Path(path) + if path.suffix.lower() == ".dng": + return dng_to_rgb(path, output_size=None) + img = _open_rgb_image(path) + return np.array(img, dtype=np.float32) / 255.0 + + +def image_to_base64(path: Path, max_size: Optional[Tuple[int, int]] = None) -> str: + """Encode image to base64 for LLM. Optionally resize to max_size to limit payload.""" + path = Path(path) + img = _open_rgb_image(path) + if max_size: + img.thumbnail(max_size, Image.Resampling.LANCZOS) + import io + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=90) + return base64.b64encode(buf.getvalue()).decode("utf-8") + + +def retrieve_similar( + image_path: Path, + top_k: int = 50, + top_n: int = 5, +) -> List[Dict[str, Any]]: + """ + Load image, embed, search Azure AI Search, rerank by lighting/color stats. + Returns list of {image_id, recipe (dict), rerank_score}. + """ + s = get_settings() + if not s.azure_search_configured(): + raise RuntimeError("Azure AI Search not configured. Set AZURE_SEARCH_* in .env") + + rgb = load_image_rgb_224(image_path) + user_stats = compute_image_stats(rgb) + + embedder = get_embedder() + vector = embedder.encode_image(rgb).tolist() + store = AzureSearchVectorStore() + hits = store.search(vector, top_k=top_k) + + if not hits: + return [] + + for h in hits: + raw = h.get("image_stats") + if isinstance(raw, str): + try: + h["_stats"] = json.loads(raw) + except json.JSONDecodeError: + h["_stats"] = {} + else: + h["_stats"] = raw or {} + + scored: List[Tuple[float, Dict]] = [ + (rerank_score(user_stats, h["_stats"]), h) + for h in hits + ] + scored.sort(key=lambda x: -x[0]) + top = scored[:top_n] + + out = [] + for score, h in top: + recipe_str = h.get("recipe") or "{}" + try: + recipe = json.loads(recipe_str) + except json.JSONDecodeError: + recipe = {} + out.append({ + "image_id": h.get("image_id", ""), + "recipe": recipe, + "rerank_score": score, + }) + return out + + +def get_llm_suggestions( + base64_img: str, + expert_recipe: Dict[str, Any], + current_parameters: Optional[Dict[str, Any]] = None, + image_stats: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """ + Call Azure OpenAI with user image + expert recipe; return JSON with + summary and suggested_edits (keys expected by editing API or local apply). + """ + from openai import AzureOpenAI + + s = get_settings() + if not s.azure_openai_configured(): + raise RuntimeError("Azure OpenAI not configured. Set AZURE_OPENAI_* in .env") + + required_keys = [ + "exposure", "contrast", "highlights", "shadows", + "whites", "blacks", "temperature", "tint", + "vibrance", "saturation", + ] + + system_prompt = f""" +You are a professional photo editing agent. +Analyze the user image together with the provided expert recipe and decide on global edits +that will make the photo look more polished and visually pleasing. + +Write the "summary" as a clear, photographer‑friendly explanation formatted as **Markdown**: +- Focus on **reasoning** (why each change helps visually), not just repeating numbers. +- Start with 1–2 normal sentences diagnosing the image (exposure, contrast, color balance, mood, subject separation, etc.). +- Then include **3–6 Markdown bullet points**, each in this pattern: + `- **Adjustment name**: visual problem -> reasoning -> expected visual impact.` +- Use **bold** to emphasize key visual issues and adjustment names. +- End with 1 concise sentence describing the final look/mood. +Do NOT use code fences in the summary. + +IMPORTANT: You must output a JSON object with exactly two fields: +1. "summary": The Markdown explanation described above. Keep it rationale-first and avoid listing current/suggested/delta values line-by-line. +2. "suggested_edits": A dictionary containing ALL of these specific keys: {required_keys}. You may use the expert recipe and the image itself to choose these values; the current parameters are only for explanation, not constraints. +Use numeric values appropriate for a photo editing API (e.g. exposure in EV or scale, temperature in Kelvin). +""" + + user_prompt = f""" +Reference Expert Recipe (single aggregated guidance recipe): {json.dumps(expert_recipe)} + +Approximate current global parameters for the uploaded image (may be noisy but in the same units as your sliders): +{json.dumps(current_parameters or {}, ensure_ascii=False)} + +Technical image statistics (brightness/clipping/saturation): +{json.dumps(image_stats or {}, ensure_ascii=False)} + +Please analyze my image and provide the JSON output described above. +""" + + client = AzureOpenAI( + azure_endpoint=s.azure_openai_endpoint, + api_key=s.azure_openai_key, + api_version=s.azure_openai_api_version, + ) + + response = client.chat.completions.create( + model=s.azure_openai_deployment, + messages=[ + {"role": "system", "content": system_prompt}, + { + "role": "user", + "content": [ + {"type": "text", "text": user_prompt}, + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_img}"}}, + ], + }, + ], + response_format={"type": "json_object"}, + ) + + result = json.loads(response.choices[0].message.content) + return result + + +def _llm_edits_to_recipe(edits: Dict[str, Any]) -> EditRecipe: + """Map LLM suggested_edits to EditRecipe. Normalize keys and types.""" + # LLM may use different scales; we map to our schema (exposure in EV, temperature in K, etc.) + return EditRecipe( + exposure=float(edits.get("exposure", 0)), + brightness=float(edits.get("brightness", 0)), + contrast=float(edits.get("contrast", 0)), + highlights=float(edits.get("highlights", 0)), + shadows=float(edits.get("shadows", 0)), + whites=float(edits.get("whites", 0)), + blacks=float(edits.get("blacks", 0)), + temperature=float(edits.get("temperature", 0)), + tint=float(edits.get("tint", 0)), + vibrance=float(edits.get("vibrance", 0)), + saturation=float(edits.get("saturation", 0)), + ) + + +def _safe_float(v: Any, default: float = 0.0) -> float: + try: + return float(v) + except (TypeError, ValueError): + return default + + +def _apply_brightness_guardrails( + suggested_edits: Dict[str, Any], + current_parameters: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + """ + Prevent overly bright outputs by constraining key tone sliders relative + to estimated current values. This keeps edits natural while still allowing + meaningful changes. + """ + cur = current_parameters or {} + out: Dict[str, Any] = dict(suggested_edits or {}) + + # Exposure: cap brightening jump and keep within sane absolute EV range. + cur_exp = _safe_float(cur.get("exposure", 0.0), 0.0) + sug_exp = _safe_float(out.get("exposure", cur_exp), cur_exp) + sug_exp = float(np.clip(sug_exp, cur_exp - 0.7, cur_exp + 0.6)) + sug_exp = float(np.clip(sug_exp, -2.0, 2.0)) + out["exposure"] = round(sug_exp, 4) + + def clamp_relative( + key: str, + up: float, + down: float, + abs_min: float = -100.0, + abs_max: float = 100.0, + ) -> None: + cur_v = _safe_float(cur.get(key, 0.0), 0.0) + sug_v = _safe_float(out.get(key, cur_v), cur_v) + sug_v = float(np.clip(sug_v, cur_v - down, cur_v + up)) + sug_v = float(np.clip(sug_v, abs_min, abs_max)) + out[key] = round(sug_v, 4) + + # Tone sliders most responsible for over-bright output. + clamp_relative("whites", up=15.0, down=25.0, abs_min=-60.0, abs_max=60.0) + clamp_relative("highlights", up=12.0, down=35.0, abs_min=-60.0, abs_max=60.0) + clamp_relative("shadows", up=25.0, down=20.0, abs_min=-60.0, abs_max=60.0) + clamp_relative("contrast", up=20.0, down=25.0, abs_min=-60.0, abs_max=60.0) + clamp_relative("vibrance", up=18.0, down=20.0, abs_min=-50.0, abs_max=50.0) + clamp_relative("saturation", up=15.0, down=20.0, abs_min=-50.0, abs_max=50.0) + + # Temperature: avoid excessive warming/cooling jumps. + cur_temp = _safe_float(cur.get("temperature", 5500.0), 5500.0) + sug_temp = _safe_float(out.get("temperature", cur_temp), cur_temp) + sug_temp = float(np.clip(sug_temp, cur_temp - 1200.0, cur_temp + 1200.0)) + sug_temp = float(np.clip(sug_temp, 2500.0, 12000.0)) + out["temperature"] = round(sug_temp, 2) + + return out + + +def _mean_recipe_from_candidates(candidates: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Build one aggregated recipe by averaging numeric fields across candidates. + Non-numeric fields are ignored. + """ + sums: Dict[str, float] = {} + counts: Dict[str, int] = {} + + for c in candidates: + recipe = c.get("recipe", {}) or {} + if not isinstance(recipe, dict): + continue + for k, v in recipe.items(): + try: + fv = float(v) + except (TypeError, ValueError): + continue + sums[k] = sums.get(k, 0.0) + fv + counts[k] = counts.get(k, 0) + 1 + + out: Dict[str, Any] = {} + for k, total in sums.items(): + cnt = counts.get(k, 0) + if cnt > 0: + out[k] = total / cnt + return out + + +def apply_edits( + image_path: Path, + edit_parameters: Dict[str, Any], + output_path: Path, + use_api: bool = False, +) -> bool: + """ + Apply edits to image and save. If use_api and EDITING_API_URL set, call external API; + else apply locally with apply_recipe_linear (no baseline). + """ + if use_api: + s = get_settings() + if not s.editing_api_configured(): + raise RuntimeError("EDITING_API_URL not set. Use --local to apply edits locally.") + import requests + with open(image_path, "rb") as f: + base64_img = base64.b64encode(f.read()).decode("utf-8") + payload = {"image": base64_img, "recipe": edit_parameters} + try: + resp = requests.post(s.editing_api_url, json=payload, timeout=60) + if resp.status_code != 200: + return False + result = resp.json() + edited_b64 = result.get("edited_image") + if not edited_b64: + return False + img_data = base64.b64decode(edited_b64) + output_path.parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "wb") as f: + f.write(img_data) + return True + except Exception: + return False + + # Local apply + rgb = load_image_rgb_full(image_path) + recipe = _llm_edits_to_recipe(edit_parameters) + edited = apply_recipe_linear(rgb, recipe, baseline_recipe=None) + output_path.parent.mkdir(parents=True, exist_ok=True) + out_u8 = (np.clip(edited, 0, 1) * 255).astype(np.uint8) + out_img = Image.fromarray(out_u8) + suffix = output_path.suffix.lower() + if suffix in {".jpg", ".jpeg"}: + # Save JPEG with higher quality to reduce compression artifacts. + out_img.save(output_path, quality=95, subsampling=0, optimize=True) + else: + out_img.save(output_path) + return True + + +def run_pipeline( + image_path: Path, + output_path: Path, + top_k: int = 50, + top_n: int = 1, + use_editing_api: bool = False, + max_image_size_llm: Optional[Tuple[int, int]] = (1024, 1024), + use_multi_expert_context: bool = False, + context_top_n: int = 5, + use_brightness_guardrail: bool = True, + progress_callback: Optional[Callable[[str], None]] = None, +) -> Dict[str, Any]: + """ + Full pipeline: retrieve similar β†’ LLM β†’ apply β†’ save. + Returns dict with summary, suggested_edits, expert_recipe_used, success. + """ + image_path = Path(image_path) + output_path = Path(output_path) + if not image_path.exists(): + raise FileNotFoundError(f"Image not found: {image_path}") + + if progress_callback: + progress_callback("retrieving") + + # 1) Retrieve + rerank + effective_top_n = max(top_n, context_top_n if use_multi_expert_context else top_n) + results = retrieve_similar(image_path, top_k=top_k, top_n=effective_top_n) + if not results: + raise RuntimeError("No similar images found in index. Run build_vector_index.py and ensure Azure Search is configured.") + + expert = results[0] + expert_recipe = expert["recipe"] + expert_candidates_used = results[: max(1, context_top_n)] if use_multi_expert_context else [expert] + recipe_for_llm = ( + _mean_recipe_from_candidates(expert_candidates_used) + if use_multi_expert_context and len(expert_candidates_used) > 1 + else expert_recipe + ) + + if progress_callback: + progress_callback("consulting") + + # 2) LLM + base64_img = image_to_base64(image_path, max_size=max_image_size_llm) + # Estimate current parameters for explanation only; suggested_edits still come + # from the expert recipe + image analysis. + current_params = estimate_current_parameters(image_path) + user_stats = compute_image_stats(load_image_rgb_224(image_path)) + llm_out = get_llm_suggestions( + base64_img, + recipe_for_llm, + current_parameters=current_params, + image_stats=user_stats, + ) + summary = llm_out.get("summary", "") + suggested_edits = llm_out.get("suggested_edits", {}) + if use_brightness_guardrail: + suggested_edits = _apply_brightness_guardrails(suggested_edits, current_params) + + if progress_callback: + progress_callback("applying") + + # 3) Apply and save + success = apply_edits(image_path, suggested_edits, output_path, use_api=use_editing_api) + + if progress_callback: + progress_callback("done") + + return { + "summary": summary, + "suggested_edits": suggested_edits, + "expert_recipe_used": recipe_for_llm, + "expert_candidates_used": expert_candidates_used, + "expert_image_id": expert.get("image_id", ""), + "success": success, + "output_path": str(output_path) if success else None, + } diff --git a/photo_editor/vector_store/__init__.py b/photo_editor/vector_store/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..accfcfd68e65845a6379ff4c8b069913454d93b7 --- /dev/null +++ b/photo_editor/vector_store/__init__.py @@ -0,0 +1,8 @@ +from .azure_search import ( + AzureSearchVectorStore, + ensure_index, + get_index_stats, + upload_documents, +) + +__all__ = ["AzureSearchVectorStore", "ensure_index", "get_index_stats", "upload_documents"] diff --git a/photo_editor/vector_store/__pycache__/__init__.cpython-313.pyc b/photo_editor/vector_store/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3ad08f09718c70bd8f95923f07e89db33bb370e1 Binary files /dev/null and b/photo_editor/vector_store/__pycache__/__init__.cpython-313.pyc differ diff --git a/photo_editor/vector_store/__pycache__/azure_search.cpython-313.pyc b/photo_editor/vector_store/__pycache__/azure_search.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0004b2929003c443bcfce7e7b8a4d302f6bccb27 Binary files /dev/null and b/photo_editor/vector_store/__pycache__/azure_search.cpython-313.pyc differ diff --git a/photo_editor/vector_store/azure_search.py b/photo_editor/vector_store/azure_search.py new file mode 100644 index 0000000000000000000000000000000000000000..75c168cb315c32e7172ddab1426355e84376a744 --- /dev/null +++ b/photo_editor/vector_store/azure_search.py @@ -0,0 +1,217 @@ +""" +Azure AI Search vector index for FiveK embeddings + recipe payload. +Used by: build script (upload), future API (query for RAG). +""" +import base64 +import json +import logging +from typing import Any, Dict, List, Optional + +from photo_editor.config import get_settings +from photo_editor.lrcat.schema import EditRecipe + +logger = logging.getLogger(__name__) + + +def _safe_document_key(image_id: str) -> str: + """Azure Search keys allow only letters, digits, _, -, =. Use URL-safe base64.""" + return base64.urlsafe_b64encode(image_id.encode("utf-8")).decode("ascii").rstrip("=") + + +def _get_client(): + from azure.core.credentials import AzureKeyCredential + from azure.search.documents import SearchClient + s = get_settings() + if not s.azure_search_configured(): + raise RuntimeError( + "Azure AI Search not configured. Set AZURE_SEARCH_ENDPOINT and AZURE_SEARCH_KEY in .env" + ) + return SearchClient( + endpoint=s.azure_search_endpoint, + index_name=s.azure_search_index_name, + credential=AzureKeyCredential(s.azure_search_key), + ) + + +def ensure_index(dimension: int) -> None: + """ + Create the index if it does not exist, or recreate it if the embedding dimension + does not match (e.g. switched from CLIP 512 to Azure Vision 1024). + Schema: id (key), image_id, embedding (vector), recipe (JSON string), optional metadata. + """ + from azure.core.credentials import AzureKeyCredential + from azure.search.documents.indexes import SearchIndexClient + from azure.search.documents.indexes.models import ( + SearchField, + SearchFieldDataType, + SearchIndex, + VectorSearch, + HnswAlgorithmConfiguration, + VectorSearchProfile, + ) + s = get_settings() + if not s.azure_search_configured(): + raise RuntimeError("Set AZURE_SEARCH_ENDPOINT and AZURE_SEARCH_KEY") + client = SearchIndexClient( + endpoint=s.azure_search_endpoint, + credential=AzureKeyCredential(s.azure_search_key), + ) + index_name = s.azure_search_index_name + existing_names = [idx.name for idx in client.list_indexes()] + + if index_name in existing_names: + existing = client.get_index(index_name) + current_dim: Optional[int] = None + for f in existing.fields: + if f.name == "embedding" and getattr(f, "vector_search_dimensions", None) is not None: + current_dim = f.vector_search_dimensions + break + if current_dim == dimension: + field_names = [f.name for f in existing.fields] + if "image_stats" not in field_names: + # Add lighting/color stats field for reranking + new_fields = list(existing.fields) + [ + SearchField(name="image_stats", type=SearchFieldDataType.String), + ] + updated = SearchIndex( + name=existing.name, + fields=new_fields, + vector_search=existing.vector_search, + ) + client.create_or_update_index(updated) + logger.info("Added image_stats field to existing index '%s'.", index_name) + else: + logger.info("Index '%s' already exists with embedding dimension %s.", index_name, dimension) + return + if current_dim is not None: + logger.warning( + "Index '%s' has embedding dimension %s but embedder has dimension %s (e.g. switched CLIP↔Azure Vision). Recreating index.", + index_name, + current_dim, + dimension, + ) + else: + logger.warning("Index '%s' exists but embedding dimension could not be read. Recreating index.", index_name) + client.delete_index(index_name) + + fields = [ + SearchField(name="id", type=SearchFieldDataType.String, key=True), + SearchField(name="image_id", type=SearchFieldDataType.String, filterable=True), + SearchField( + name="embedding", + type=SearchFieldDataType.Collection(SearchFieldDataType.Single), + searchable=True, + vector_search_dimensions=dimension, + vector_search_profile_name="default-vector-profile", + ), + SearchField(name="recipe", type=SearchFieldDataType.String), # JSON + SearchField(name="image_stats", type=SearchFieldDataType.String), # JSON: brightness/clip/saturation + ] + vector_search = VectorSearch( + algorithms=[HnswAlgorithmConfiguration(name="hnsw")], + profiles=[VectorSearchProfile(name="default-vector-profile", algorithm_configuration_name="hnsw")], + ) + index = SearchIndex(name=index_name, fields=fields, vector_search=vector_search) + client.create_index(index) + logger.info("Created index '%s' with embedding dimension %s.", index_name, dimension) + + +def _doc( + image_id: str, + embedding: List[float], + recipe: EditRecipe, + image_stats: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + doc: Dict[str, Any] = { + "id": _safe_document_key(image_id), + "image_id": image_id, + "embedding": embedding, + "recipe": json.dumps(recipe.to_dict()), + } + if image_stats is not None: + doc["image_stats"] = json.dumps(image_stats) + return doc + + +def get_index_stats() -> Dict[str, Any]: + """ + Return document count and a sample of document IDs from the index. + Use to verify that uploads reached Azure AI Search. + """ + client = _get_client() + results = client.search( + search_text="*", + select=["id", "image_id"], + include_total_count=True, + top=100, + ) + total = results.get_count() + sample = [dict(r) for r in results] + return { + "total_documents": total, + "sample": sample[:10], + } + + +def upload_documents( + image_ids: List[str], + embeddings: List[List[float]], + recipes: List[EditRecipe], + stats_list: Optional[List[Dict[str, Any]]] = None, + batch_size: int = 100, +) -> None: + """Upload documents to the index. Lengths of image_ids, embeddings, recipes must match. + If stats_list is provided, its length must match; each element is stored as image_stats (JSON).""" + if not (len(image_ids) == len(embeddings) == len(recipes)): + raise ValueError("image_ids, embeddings, recipes must have same length") + if stats_list is not None and len(stats_list) != len(image_ids): + raise ValueError("stats_list length must match image_ids when provided") + client = _get_client() + for i in range(0, len(image_ids), batch_size): + batch_ids = image_ids[i : i + batch_size] + batch_emb = embeddings[i : i + batch_size] + batch_rec = recipes[i : i + batch_size] + batch_stats = (stats_list[i : i + batch_size]) if stats_list else None + docs = [ + _doc( + imid, emb, rec, + image_stats=batch_stats[j] if batch_stats else None, + ) + for j, (imid, emb, rec) in enumerate(zip(batch_ids, batch_emb, batch_rec)) + ] + client.upload_documents(documents=docs) + + +class AzureSearchVectorStore: + """Query-side helper for the inference pipeline (retrieve similar images by embedding).""" + + def __init__(self) -> None: + self._client: Optional[Any] = None + + def _client_get(self): + if self._client is None: + self._client = _get_client() + return self._client + + def search( + self, + vector: List[float], + top_k: int = 10, + filter_expr: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """Return top_k nearest documents; each has image_id, recipe (JSON string).""" + from azure.search.documents.models import VectorizedQuery + client = self._client_get() + results = client.search( + search_text=None, + vector_queries=[ + VectorizedQuery( + vector=vector, + k_nearest_neighbors=top_k, + fields="embedding", + ) + ], + filter=filter_expr, + select=["image_id", "recipe", "image_stats"], + ) + return [dict(r) for r in results] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..027b418867f6b87c5e07ff48059d2b67a716b4d3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,26 @@ +# Config and env +python-dotenv>=1.0.0 +tqdm>=4.65.0 + +# DNG / raw development +rawpy>=0.19.0 +numpy>=1.24.0 + +# Image handling +Pillow>=10.0.0 +pillow-heif>=0.16.0 +scikit-image>=0.21.0 + +# Embeddings (CLIP via transformers) +torch>=2.0.0 +transformers>=4.30.0 + +# Azure AI Search +azure-search-documents>=11.4.0 + +# Pipeline: Azure OpenAI (LLM) + optional editing API +openai>=1.0.0 +requests>=2.28.0 + +# Streamlit UI +streamlit>=1.28.0 diff --git a/scripts/sync_hf_secrets.py b/scripts/sync_hf_secrets.py new file mode 100644 index 0000000000000000000000000000000000000000..8e4307ed5bc1fa84797eea79b05384eeecbafbcf --- /dev/null +++ b/scripts/sync_hf_secrets.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +""" +Sync selected keys from a local .env file to Hugging Face Space secrets. + +Usage: + python scripts/sync_hf_secrets.py --space-id + +Optional: + python scripts/sync_hf_secrets.py --space-id --env-file .env + python scripts/sync_hf_secrets.py --space-id --token + python scripts/sync_hf_secrets.py --space-id --dry-run +""" +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +from dotenv import dotenv_values + +ALLOWED_KEYS = [ + "AZURE_SEARCH_ENDPOINT", + "AZURE_SEARCH_KEY", + "AZURE_SEARCH_INDEX_NAME", + "AZURE_OPENAI_ENDPOINT", + "AZURE_OPENAI_KEY", + "AZURE_OPENAI_DEPLOYMENT", + "AZURE_OPENAI_API_VERSION", + "AZURE_VISION_ENDPOINT", + "AZURE_VISION_KEY", + "AZURE_VISION_MODEL_VERSION", + "EDITING_API_URL", + "FIVEK_SUBSET_SIZE", +] + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Sync selected .env keys to Hugging Face Space secrets." + ) + parser.add_argument( + "--space-id", + required=True, + help="Hugging Face Space id, e.g. username/my-space", + ) + parser.add_argument( + "--env-file", + default=".env", + help="Path to .env file (default: .env)", + ) + parser.add_argument( + "--token", + default=os.getenv("HF_TOKEN"), + help="Hugging Face token (defaults to HF_TOKEN env var)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print what would be synced without updating the Space", + ) + return parser.parse_args() + + +def _load_pairs(env_file: Path) -> dict[str, str]: + if not env_file.exists(): + raise FileNotFoundError(f".env file not found: {env_file}") + + raw = dotenv_values(env_file) + pairs: dict[str, str] = {} + for key in ALLOWED_KEYS: + value = raw.get(key) + if value is None: + continue + value = str(value).strip() + if not value: + continue + pairs[key] = value + return pairs + + +def main() -> int: + args = _parse_args() + env_file = Path(args.env_file).resolve() + + try: + pairs = _load_pairs(env_file) + except Exception as exc: # pragma: no cover + print(f"ERROR: {exc}") + return 1 + + if not pairs: + print("No allowed keys with non-empty values found in .env.") + return 1 + + if args.dry_run: + print(f"[dry-run] Would sync {len(pairs)} secret(s) to {args.space_id}:") + for k in sorted(pairs): + print(f" - {k}") + return 0 + + if not args.token: + print("ERROR: Missing token. Set HF_TOKEN or pass --token.") + return 1 + + try: + from huggingface_hub import HfApi + except Exception: + print("ERROR: huggingface_hub is not installed.") + print("Install it with: pip install huggingface_hub") + return 1 + + api = HfApi(token=args.token) + print(f"Syncing {len(pairs)} secret(s) to {args.space_id}...") + for key, value in pairs.items(): + api.add_space_secret(repo_id=args.space_id, key=key, value=value) + print(f" - synced {key}") + print("Done. Your Space will restart to apply updated secrets.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())