#!/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()