| from __future__ import annotations |
|
|
| from pathlib import Path |
|
|
| from PIL import Image |
| import streamlit as st |
|
|
| from mine2real.inference import ( |
| CHECKPOINT_PATH, |
| MINECRAFT_DIR, |
| REAL_DIR, |
| TranslationResult, |
| get_device, |
| list_example_images, |
| load_model, |
| translate_image, |
| ) |
|
|
| st.set_page_config( |
| page_title="mine2real", |
| page_icon="⛏️", |
| layout="wide", |
| ) |
|
|
| CUSTOM_CSS = """ |
| <style> |
| :root { |
| --bg: #f5f1e8; |
| --panel: rgba(255, 252, 245, 0.86); |
| --ink: #17211c; |
| --accent: #3f7d4c; |
| --accent-2: #cc7a00; |
| --line: rgba(23, 33, 28, 0.12); |
| } |
| |
| .stApp { |
| background: |
| radial-gradient(circle at top left, rgba(204, 122, 0, 0.18), transparent 28%), |
| radial-gradient(circle at top right, rgba(63, 125, 76, 0.18), transparent 26%), |
| linear-gradient(180deg, #f3efe4 0%, #ece7da 100%); |
| color: var(--ink); |
| } |
| |
| .block-container { |
| padding-top: 2rem; |
| padding-bottom: 3rem; |
| max-width: 1200px; |
| } |
| |
| .hero { |
| background: linear-gradient(135deg, rgba(255,255,255,0.76), rgba(245, 234, 212, 0.84)); |
| border: 1px solid var(--line); |
| border-radius: 24px; |
| padding: 1.6rem 1.8rem; |
| box-shadow: 0 20px 70px rgba(44, 52, 46, 0.08); |
| margin-bottom: 1rem; |
| backdrop-filter: blur(10px); |
| } |
| |
| .hero h1 { |
| margin: 0 0 0.3rem 0; |
| font-size: 3rem; |
| line-height: 1; |
| } |
| |
| .hero p { |
| margin: 0; |
| font-size: 1.03rem; |
| } |
| |
| .pill-row { |
| display: flex; |
| gap: 0.7rem; |
| flex-wrap: wrap; |
| margin-top: 1rem; |
| } |
| |
| .pill { |
| padding: 0.5rem 0.8rem; |
| border-radius: 999px; |
| border: 1px solid var(--line); |
| background: rgba(255,255,255,0.65); |
| font-size: 0.9rem; |
| } |
| |
| .info-card { |
| background: var(--panel); |
| border: 1px solid var(--line); |
| border-radius: 20px; |
| padding: 1rem 1.1rem; |
| min-height: 140px; |
| } |
| |
| .section-title { |
| padding-top: 0.5rem; |
| font-weight: 700; |
| letter-spacing: 0.02em; |
| text-transform: uppercase; |
| color: #4a564e; |
| font-size: 0.83rem; |
| } |
| </style> |
| """ |
|
|
|
|
| @st.cache_resource(show_spinner="Loading CycleGAN checkpoint...") |
| def get_model(): |
| device = get_device() |
| model = load_model(CHECKPOINT_PATH, device=device) |
| return model, device |
|
|
|
|
| @st.cache_data(show_spinner=False) |
| def load_example_gallery() -> dict[str, list[Path]]: |
| return { |
| "minecraft": list_example_images(MINECRAFT_DIR, limit=5, seed=7), |
| "real": list_example_images(REAL_DIR, limit=5, seed=11), |
| } |
|
|
|
|
| def render_result(result: TranslationResult, input_label: str, output_label: str) -> None: |
| col1, col2, col3 = st.columns(3) |
| col1.image(result.source, caption=input_label, width="stretch") |
| col2.image(result.translated, caption=output_label, width="stretch") |
| col3.image( |
| result.reconstructed, |
| caption="Cycle reconstruction", |
| width="stretch", |
| ) |
|
|
|
|
| def app() -> None: |
| st.markdown(CUSTOM_CSS, unsafe_allow_html=True) |
|
|
| st.markdown( |
| """ |
| <div class="hero"> |
| <h1>mine2real</h1> |
| <p>CycleGAN-приложение для перевода пейзажей между Minecraft и реальными природными сценами.</p> |
| <div class="pill-row"> |
| <div class="pill">Domain A: Minecraft forestlike</div> |
| <div class="pill">Domain B: Real nature landscape</div> |
| <div class="pill">Inference: bidirectional</div> |
| <div class="pill">Deployment: Hugging Face Space via Docker</div> |
| </div> |
| </div> |
| """, |
| unsafe_allow_html=True, |
| ) |
|
|
| model, device = get_model() |
|
|
| meta_col1, meta_col2, meta_col3, meta_col4 = st.columns(4) |
| meta_col1.markdown( |
| """ |
| <div class="info-card"> |
| <div class="section-title">Checkpoint</div> |
| <div><code>cycle_gan_color_fix#0_epoch_10.pt</code></div> |
| </div> |
| """, |
| unsafe_allow_html=True, |
| ) |
| meta_col2.markdown( |
| f""" |
| <div class="info-card"> |
| <div class="section-title">Runtime</div> |
| <div><code>{device}</code></div> |
| </div> |
| """, |
| unsafe_allow_html=True, |
| ) |
| meta_col3.markdown( |
| """ |
| <div class="info-card"> |
| <div class="section-title">Input Format</div> |
| <div>Any image will be center-cropped and resized to <code>256x256</code> before translation.</div> |
| </div> |
| """, |
| unsafe_allow_html=True, |
| ) |
| meta_col4.markdown( |
| """ |
| <div class="info-card"> |
| <div class="section-title">Dataset</div> |
| <div><a href="https://disk.yandex.ru/d/N_G-t-oirnLynw" target="_blank">Yandex Disk</a></div> |
| </div> |
| """, |
| unsafe_allow_html=True, |
| ) |
|
|
| st.write("") |
| st.subheader("Translate Image") |
| direction_labels = { |
| "minecraft_to_real": "Minecraft -> Real -> Minecraft", |
| "real_to_minecraft": "Real -> Minecraft -> Real", |
| } |
| if hasattr(st, "segmented_control"): |
| direction = st.segmented_control( |
| "Direction", |
| options=list(direction_labels.keys()), |
| default="minecraft_to_real", |
| selection_mode="single", |
| format_func=lambda value: direction_labels[value], |
| width="stretch", |
| ) |
| else: |
| direction = st.radio( |
| "Direction", |
| options=list(direction_labels.keys()), |
| format_func=lambda value: direction_labels[value], |
| horizontal=True, |
| ) |
|
|
| uploaded = st.file_uploader( |
| "Upload one image from one of the domains", |
| type=["png", "jpg", "jpeg", "webp"], |
| accept_multiple_files=False, |
| help="The app accepts exactly one image and runs a full CycleGAN pass with reconstruction.", |
| ) |
|
|
| run_button = st.button("Translate image", type="primary", width="stretch") |
|
|
| if uploaded is not None: |
| image = Image.open(uploaded).convert("RGB") |
| if run_button: |
| with st.spinner("Generating translation..."): |
| result = translate_image(model, image, direction=direction, device=device) |
| if direction == "minecraft_to_real": |
| input_label = "A: Uploaded Minecraft scene" |
| output_label = "B: Translated real landscape" |
| else: |
| input_label = "B: Uploaded real landscape" |
| output_label = "A: Translated Minecraft-like scene" |
| render_result(result, input_label, output_label) |
| st.warning( |
| """ |
| Качество результата всё ещё оставляет желать лучшего. Это ожидаемо для CycleGAN: |
| модель хорошо переносит общую структуру сцены, но заметно слабее справляется с мелкими деталями, |
| локальными текстурами и стабильной цветопередачей. |
| |
| Часто видны такие проблемы: |
| - смазывание или потеря мелких объектов и фактуры; |
| - упрощение геометрии и превращение деталей в более грубые пятна; |
| - цветовые сдвиги, неестественные оттенки и местами "грязная" палитра. |
| |
| Что удалось частично улучшить в обучении: |
| - `ReplayBuffer` помог сделать обучение дискриминатора стабильнее и немного снизить резкие артефакты и хаотичные скачки стиля; |
| - `VggPerceptualLoss` / perceptual fine-tuning помог лучше сохранять общую визуальную структуру и частично уменьшил самые грубые проблемы с цветом: |
| менее случайные цветовые сдвиги, меньше выцветания и чуть более связная палитра. |
| |
| Но полностью убрать эти ограничения не удалось: для тонкой прорисовки деталей и аккуратной работы с цветом базовый CycleGAN всё равно довольно ограничен. |
| """ |
| ) |
| else: |
| st.image(image, caption="Uploaded image", width="stretch") |
| else: |
| st.info("Upload an image and click `Translate image` to run the model.") |
|
|
| st.write("") |
| st.subheader("Submission Artifacts") |
| st.markdown( |
| """ |
| - Notebook: `notebooks/lab5.ipynb` |
| - Checkpoint: `checkpoints/cycle_gan_color_fix#0_epoch_10.pt` |
| - Example gallery in repo: `demo_gallery/` (5 + 5 random images); full training data stays under `data/datasets/` when unpacked locally |
| """ |
| ) |
|
|
| st.write("") |
| st.markdown("---") |
| st.subheader("Dataset Examples") |
| st.caption("Reference samples from both domains. This section is intentionally separated from the working inference interface.") |
|
|
| galleries = load_example_gallery() |
| if not galleries["minecraft"] and not galleries["real"]: |
| st.info( |
| "Example gallery is empty: ensure `demo_gallery/` is present in the app checkout, or restore `data/datasets/` from the Yandex Disk link above." |
| ) |
| block_a, block_b = st.columns(2) |
|
|
| with block_a: |
| st.markdown( |
| """ |
| <div class="info-card"> |
| <div class="section-title">Domain A</div> |
| <div>Minecraft forestlike</div> |
| </div> |
| """, |
| unsafe_allow_html=True, |
| ) |
| st.write("") |
| cols = st.columns(2) |
| for idx, image_path in enumerate(galleries["minecraft"]): |
| cols[idx % 2].image(str(image_path), caption=image_path.name, width="stretch") |
|
|
| with block_b: |
| st.markdown( |
| """ |
| <div class="info-card"> |
| <div class="section-title">Domain B</div> |
| <div>Real nature landscape</div> |
| </div> |
| """, |
| unsafe_allow_html=True, |
| ) |
| st.write("") |
| cols = st.columns(2) |
| for idx, image_path in enumerate(galleries["real"]): |
| cols[idx % 2].image(str(image_path), caption=image_path.name, width="stretch") |
|
|
|
|
| if __name__ == "__main__": |
| app() |
|
|