#!/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 import streamlit.components.v1 as components 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 _collapse_sidebar() -> None: """Collapse Streamlit sidebar via whichever toggle exists in this version.""" components.html( """ """, height=0, width=0, ) def main() -> None: st.set_page_config(page_title="LumiGrade AI", page_icon="πŸ“·", layout="wide") if "sidebar_collapsed" not in st.session_state: st.session_state["sidebar_collapsed"] = False if "run_pipeline_requested" not in st.session_state: st.session_state["run_pipeline_requested"] = False if "selected_image_path" not in st.session_state: st.session_state["selected_image_path"] = "" if "is_processing" not in st.session_state: st.session_state["is_processing"] = False if "refresh_after_run" not in st.session_state: st.session_state["refresh_after_run"] = False collapse_css = "" if st.session_state["sidebar_collapsed"]: collapse_css = """ /* Force-hide sidebar after run is triggered; avoids version-specific JS toggles. */ [data-testid="stSidebar"] { display: none !important; } """ 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.") if st.session_state["sidebar_collapsed"]: if st.button("βš™οΈ Show Inputs", key="show_inputs_btn", disabled=st.session_state["is_processing"]): st.session_state["sidebar_collapsed"] = False st.rerun() # 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 = Path(st.session_state["selected_image_path"]) if st.session_state["selected_image_path"] else 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="Upload JPEG/PNG/DNG/HEIC/HEIF to run the edit recommendation pipeline.", ) 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 st.session_state["selected_image_path"] = str(target) run_clicked = st.button( "✨ Generate Edit Recommendations", type="primary", use_container_width=True, disabled=st.session_state["is_processing"], ) status = st.empty() if image_path is None: status.info("Provide an image to run.") if run_clicked and image_path is not None: # Mark busy before rerun so any control rendered on next pass is disabled. st.session_state["is_processing"] = True st.session_state["sidebar_collapsed"] = True st.session_state["run_pipeline_requested"] = True st.rerun() should_run_pipeline = st.session_state.pop("run_pipeline_requested", False) if should_run_pipeline and st.session_state["selected_image_path"]: image_path = Path(st.session_state["selected_image_path"]) if should_run_pipeline and image_path is not None: st.session_state["is_processing"] = True _collapse_sidebar() 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") finally: st.session_state["is_processing"] = False # Button states are computed at render time; rerun once so controls # immediately reflect processing completion (re-enable Show Inputs). st.session_state["refresh_after_run"] = True if st.session_state.get("refresh_after_run"): st.session_state["refresh_after_run"] = False st.rerun() display_input_path = image_path if display_input_path is None and st.session_state.get("pipeline_input_path"): display_input_path = Path(st.session_state["pipeline_input_path"]) with st.container(border=True): st.subheader("Results Dashboard") st.markdown("### πŸ“Š Pipeline Analysis & Recommendations") # Show result if available if ( display_input_path is not None and st.session_state.get("pipeline_result") and st.session_state.get("pipeline_input_path") == str(display_input_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 ( display_input_path is not None and st.session_state.get("pipeline_result") and st.session_state.get("pipeline_input_path") == str(display_input_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(display_input_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()