Update app.py
Browse files
app.py
CHANGED
|
@@ -6,7 +6,6 @@ import zipfile
|
|
| 6 |
import tempfile
|
| 7 |
from pathlib import Path
|
| 8 |
from typing import Optional, List, Dict
|
| 9 |
-
|
| 10 |
import numpy as np
|
| 11 |
import pandas as pd
|
| 12 |
import nibabel as nib
|
|
@@ -17,6 +16,7 @@ from matplotlib import animation
|
|
| 17 |
from PIL import Image
|
| 18 |
from skimage.measure import label as cc_label, regionprops
|
| 19 |
import streamlit as st
|
|
|
|
| 20 |
|
| 21 |
# ---- Custom layer used by model
|
| 22 |
from utils.layer_util import ResizeAndConcatenate
|
|
@@ -49,7 +49,7 @@ ISLAND_MIN_SLICE_SPAN = 2
|
|
| 49 |
ISLAND_MIN_AREA_PER_SLICE = 10
|
| 50 |
ISLAND_CENTROID_DIST_THRESH = 40
|
| 51 |
|
| 52 |
-
# Orientation / display
|
| 53 |
ORIENT_TARGET = None # None (keep native), "LPS", or "RAS"
|
| 54 |
DISPLAY_MATCH_DICOM = False
|
| 55 |
DISPLAY_RULES = {
|
|
@@ -69,6 +69,125 @@ MID_A_MYO_MIN = 30
|
|
| 69 |
GIF_FPS = 2
|
| 70 |
GIF_DPI = 300
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
# =========================
|
| 73 |
# Small utilities
|
| 74 |
# =========================
|
|
@@ -450,68 +569,71 @@ def process_nifti_case(nifti_path: str, model, rows_acc: List[Dict], per_frame_r
|
|
| 450 |
'PixelSpacing_col_mm': spacing['col_mm'],
|
| 451 |
})
|
| 452 |
|
| 453 |
-
# GIF: ED vs ES (slices animate)
|
| 454 |
gif_path = gif_animation_for_patient_pred_only(imgs_4d, preds_4d, pid, ed_idx, es_idx, GIFS_DIR)
|
| 455 |
try:
|
| 456 |
-
# Streamlit uses 'use_column_width'; keep it, but guard against TypeError on older builds
|
| 457 |
st.image(gif_path, caption="Generated GIF", use_column_width=True)
|
| 458 |
except TypeError:
|
| 459 |
st.image(gif_path, caption="Generated GIF")
|
| 460 |
log(f"GIF saved: {gif_path}")
|
| 461 |
|
|
|
|
| 462 |
# =========================
|
| 463 |
# UI
|
| 464 |
# =========================
|
| 465 |
-
# def main():
|
| 466 |
-
# st.header("Data Upload")
|
| 467 |
-
# uploaded_zip = st.file_uploader("Upload ZIP of NIfTI files", type="zip")
|
| 468 |
-
|
| 469 |
def main():
|
| 470 |
-
st.set_page_config(layout="wide")
|
|
|
|
| 471 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
st.markdown(
|
| 473 |
"""
|
| 474 |
-
<
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
Open-Source Pre-Clinical Image Segmentation:
|
| 478 |
-
</span>
|
| 479 |
-
<span style='margin-left: 40px; font-size: 28px; display: block;'>
|
| 480 |
-
Mouse cardiac MRI datasets with a deep learning segmentation framework
|
| 481 |
-
</span>
|
| 482 |
-
submitted to the Journal of Cardiovascular Magnetic Resonance
|
| 483 |
-
</h1>
|
| 484 |
""",
|
| 485 |
unsafe_allow_html=True
|
| 486 |
)
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
""
|
| 490 |
-
|
| 491 |
-
In this paper, we present the first publicly-available pre-clinical cardiac MRI dataset, along with an open-source DL segmentation model
|
| 492 |
-
<br>(both available on GitHub: https://github.com/mrphys/Open-Source_Pre-Clinical_Segmentation.git)<br>
|
| 493 |
-
and this web-based interface for easy deployment.
|
| 494 |
-
<br><br>
|
| 495 |
-
The dataset comprises of complete cine short-axis cardiac MRI images from 130 mice with diverse phenotypes.
|
| 496 |
-
<br>It also contains expert manual segmentations of left ventricular (LV) blood pool and myocardium at end-diastole, end-systole, as well as additional timeframes with artefacts to improve robustness.
|
| 497 |
-
|
| 498 |
-
<br>Using this resource, we developed an open-source DL segmentation model based on the UNet3+ architecture.
|
| 499 |
-
|
| 500 |
-
This Streamlit application consits of the inference model to enable easy-to-use interface of our DL segmentation model, without the need for local installation.
|
| 501 |
-
<br>The applictaion requires the complete SAX cine image data to be uploaded in NIfTI format, as a zip file using the simple file browser, below.
|
| 502 |
-
|
| 503 |
-
Pre-processing and inference is then performed on all 2D images.
|
| 504 |
-
<br>The resulting blood-pool and myocardial volumes are combined across all slices at each timeframe and output in a .csv file.
|
| 505 |
-
<br>The blood-pool volumes are used to easily identify ED and ES, and these volumes are displayed as a GIF, with the segmentations overlaid.
|
| 506 |
-
""",
|
| 507 |
-
unsafe_allow_html=True,
|
| 508 |
)
|
| 509 |
-
|
| 510 |
-
st.header("Data Upload 🐭")
|
| 511 |
-
|
| 512 |
-
uploaded_zip = st.file_uploader("Upload ZIP of NIfTI files", type="zip")
|
| 513 |
-
|
| 514 |
|
|
|
|
| 515 |
def extract_zip(zip_path, extract_to):
|
| 516 |
os.makedirs(extract_to, exist_ok=True)
|
| 517 |
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
|
@@ -525,10 +647,8 @@ def main():
|
|
| 525 |
st.stop()
|
| 526 |
|
| 527 |
if st.button("Process Data"):
|
| 528 |
-
# Show the real filename in the spinner and use it for the temp file name
|
| 529 |
zip_label = uploaded_zip.name or "ZIP"
|
| 530 |
with st.spinner(f"Processing {zip_label}..."):
|
| 531 |
-
# Save & extract
|
| 532 |
tmpdir = tempfile.mkdtemp()
|
| 533 |
zpath = os.path.join(tmpdir, uploaded_zip.name)
|
| 534 |
with open(zpath, "wb") as f:
|
|
@@ -551,7 +671,7 @@ def main():
|
|
| 551 |
model = keras.models.load_model(
|
| 552 |
MODEL_PATH,
|
| 553 |
custom_objects={
|
| 554 |
-
'focal_tversky_loss': None,
|
| 555 |
'dice_coef_no_bkg': None,
|
| 556 |
'ResizeAndConcatenate': ResizeAndConcatenate,
|
| 557 |
'dice_myo': None,
|
|
@@ -575,14 +695,19 @@ def main():
|
|
| 575 |
csv_path = write_all_in_one_csv(rows, per_frame_rows, CSV_DIR)
|
| 576 |
st.success(f"Processed {len(rows)} NIfTI file(s).")
|
| 577 |
csv_download_name = f"{Path(zip_label).stem}_Results.csv"
|
|
|
|
|
|
|
| 578 |
with open(csv_path, "rb") as f:
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
|
|
|
|
|
|
| 587 |
if __name__ == "__main__":
|
| 588 |
main()
|
|
|
|
|
|
| 6 |
import tempfile
|
| 7 |
from pathlib import Path
|
| 8 |
from typing import Optional, List, Dict
|
|
|
|
| 9 |
import numpy as np
|
| 10 |
import pandas as pd
|
| 11 |
import nibabel as nib
|
|
|
|
| 16 |
from PIL import Image
|
| 17 |
from skimage.measure import label as cc_label, regionprops
|
| 18 |
import streamlit as st
|
| 19 |
+
from textwrap import dedent
|
| 20 |
|
| 21 |
# ---- Custom layer used by model
|
| 22 |
from utils.layer_util import ResizeAndConcatenate
|
|
|
|
| 49 |
ISLAND_MIN_AREA_PER_SLICE = 10
|
| 50 |
ISLAND_CENTROID_DIST_THRESH = 40
|
| 51 |
|
| 52 |
+
# Orientation / display
|
| 53 |
ORIENT_TARGET = None # None (keep native), "LPS", or "RAS"
|
| 54 |
DISPLAY_MATCH_DICOM = False
|
| 55 |
DISPLAY_RULES = {
|
|
|
|
| 69 |
GIF_FPS = 2
|
| 70 |
GIF_DPI = 300
|
| 71 |
|
| 72 |
+
# =========================
|
| 73 |
+
# Branding / UI
|
| 74 |
+
# =========================
|
| 75 |
+
# Raw logo URL so Streamlit can fetch it
|
| 76 |
+
LOGO_URL = "https://raw.githubusercontent.com/whanisa/Segmentation/main/icon/logo.png"
|
| 77 |
+
LOGO_LINK = "https://github.com/whanisa/Segmentation"
|
| 78 |
+
|
| 79 |
+
# Optional: adjust fixed logo size and safe top spacing
|
| 80 |
+
LOGO_HEIGHT_PX = 120
|
| 81 |
+
SAFE_INSET_PX = 18
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# --- CSS
|
| 85 |
+
def _inject_layout_css():
|
| 86 |
+
# Tune these three knobs as needed
|
| 87 |
+
CONTENT_MEASURE_PX = 920 # width of the hero + paragraphs column
|
| 88 |
+
LEFT_OFFSET_PX = 40 # push BOTH text and uploader to the right
|
| 89 |
+
UPLOAD_WIDTH_PX = 420 # make uploader column narrower
|
| 90 |
+
|
| 91 |
+
st.markdown(f"""
|
| 92 |
+
<style>
|
| 93 |
+
:root {{
|
| 94 |
+
--content-measure: {CONTENT_MEASURE_PX}px;
|
| 95 |
+
--left-offset: {LEFT_OFFSET_PX}px;
|
| 96 |
+
--upload-width: {UPLOAD_WIDTH_PX}px;
|
| 97 |
+
}}
|
| 98 |
+
|
| 99 |
+
/* Compact page padding */
|
| 100 |
+
.appview-container .main .block-container {{
|
| 101 |
+
padding-top: 0.75rem;
|
| 102 |
+
padding-bottom: 1rem;
|
| 103 |
+
}}
|
| 104 |
+
|
| 105 |
+
/* Outer wrapper */
|
| 106 |
+
.content-wrap {{
|
| 107 |
+
width: min(1300px, 100%);
|
| 108 |
+
margin: 0 auto;
|
| 109 |
+
padding: 0 18px;
|
| 110 |
+
box-sizing: border-box;
|
| 111 |
+
}}
|
| 112 |
+
|
| 113 |
+
/* Text column (hero + paragraphs): same width + same left offset as uploader */
|
| 114 |
+
.measure-wrap {{
|
| 115 |
+
max-width: var(--content-measure);
|
| 116 |
+
margin-left: var(--left-offset);
|
| 117 |
+
margin-right: auto;
|
| 118 |
+
}}
|
| 119 |
+
|
| 120 |
+
/* HERO: one H1 + inline spans; remove all extra margins */
|
| 121 |
+
.hero-title {{
|
| 122 |
+
text-align: left;
|
| 123 |
+
font-size: 32px;
|
| 124 |
+
line-height: 1.25; /* exact line-height */
|
| 125 |
+
font-weight: 800;
|
| 126 |
+
margin: 0; /* kill default h1 margins */
|
| 127 |
+
}}
|
| 128 |
+
.hero-title .sub {{
|
| 129 |
+
display: block; /* forces each to its own line */
|
| 130 |
+
font-size: 28px; /* exact size */
|
| 131 |
+
line-height: 1.25; /* keep same rhythm */
|
| 132 |
+
margin: 0; /* kill gaps between lines */
|
| 133 |
+
}}
|
| 134 |
+
|
| 135 |
+
/* Body text */
|
| 136 |
+
.text-wrap p {{
|
| 137 |
+
margin: 0 0 14px 0;
|
| 138 |
+
font-size: 17px;
|
| 139 |
+
line-height: 1.5;
|
| 140 |
+
text-align: justify;
|
| 141 |
+
color: #333;
|
| 142 |
+
}}
|
| 143 |
+
.text-wrap a {{
|
| 144 |
+
color: #0066cc !important;
|
| 145 |
+
text-decoration: underline !important;
|
| 146 |
+
}}
|
| 147 |
+
|
| 148 |
+
/* Uploader block: same left offset; narrower width so it doesn't drift right */
|
| 149 |
+
#upload-wrap {{
|
| 150 |
+
max-width: var(--upload-width);
|
| 151 |
+
margin-left: var(--left-offset);
|
| 152 |
+
margin-right: auto;
|
| 153 |
+
}}
|
| 154 |
+
#upload-wrap [data-testid="stFileUploader"] {{
|
| 155 |
+
width: 100% !important;
|
| 156 |
+
margin-left: 0 !important;
|
| 157 |
+
margin-right: 0 !important;
|
| 158 |
+
}}
|
| 159 |
+
#upload-wrap [data-testid="stFileUploaderDropzone"] {{
|
| 160 |
+
padding-left: 0 !important;
|
| 161 |
+
padding-right: 44px !important;
|
| 162 |
+
}}
|
| 163 |
+
|
| 164 |
+
/* Fixed, NON-INTERACTIVE logo (won't steal clicks) */
|
| 165 |
+
#fixed-logo {{
|
| 166 |
+
position: fixed;
|
| 167 |
+
top: 40px;
|
| 168 |
+
left: 25px;
|
| 169 |
+
z-index: 5; /* lower than big overlays; high enough to be visible */
|
| 170 |
+
pointer-events: none; /* <-- key: pass clicks through */
|
| 171 |
+
}}
|
| 172 |
+
#fixed-logo img {{
|
| 173 |
+
height: 120px;
|
| 174 |
+
display: block;
|
| 175 |
+
}}
|
| 176 |
+
.top-spacer {{ height: 120px; }}
|
| 177 |
+
|
| 178 |
+
/* Remove Streamlit paperclip/anchor icons on headings */
|
| 179 |
+
[data-testid="stHeaderAction"] {{ display: none !important; }}
|
| 180 |
+
.stMarkdown h1 a, .stMarkdown h2 a, .stMarkdown h3 a {{ display: none !important; }}
|
| 181 |
+
</style>
|
| 182 |
+
|
| 183 |
+
<!-- Fixed, NON-clickable logo (no <a> wrapper) -->
|
| 184 |
+
<div id="fixed-logo" aria-hidden="true" role="presentation">
|
| 185 |
+
<img src="{LOGO_URL}" alt="">
|
| 186 |
+
</div>
|
| 187 |
+
<div class="top-spacer"></div>
|
| 188 |
+
""", unsafe_allow_html=True)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
# =========================
|
| 192 |
# Small utilities
|
| 193 |
# =========================
|
|
|
|
| 569 |
'PixelSpacing_col_mm': spacing['col_mm'],
|
| 570 |
})
|
| 571 |
|
| 572 |
+
# GIF: ED vs ES (slices animate)
|
| 573 |
gif_path = gif_animation_for_patient_pred_only(imgs_4d, preds_4d, pid, ed_idx, es_idx, GIFS_DIR)
|
| 574 |
try:
|
|
|
|
| 575 |
st.image(gif_path, caption="Generated GIF", use_column_width=True)
|
| 576 |
except TypeError:
|
| 577 |
st.image(gif_path, caption="Generated GIF")
|
| 578 |
log(f"GIF saved: {gif_path}")
|
| 579 |
|
| 580 |
+
|
| 581 |
# =========================
|
| 582 |
# UI
|
| 583 |
# =========================
|
|
|
|
|
|
|
|
|
|
|
|
|
| 584 |
def main():
|
| 585 |
+
st.set_page_config(layout="wide", initial_sidebar_state="collapsed")
|
| 586 |
+
_inject_layout_css()
|
| 587 |
|
| 588 |
+
# Optional: also register the logo with Streamlit (for favicon/header in newer builds)
|
| 589 |
+
try:
|
| 590 |
+
st.logo(LOGO_URL, size="large", link=LOGO_LINK, icon_image=LOGO_URL)
|
| 591 |
+
except Exception:
|
| 592 |
+
pass
|
| 593 |
+
|
| 594 |
+
# ---------- HERO (kept indentation; dedent removes it safely) ----------
|
| 595 |
+
HERO_HTML = dedent("""
|
| 596 |
+
<div class="content-wrap">
|
| 597 |
+
<div class="measure-wrap">
|
| 598 |
+
<div class="hero-wrap">
|
| 599 |
+
<h1 class="hero-title">
|
| 600 |
+
This Hugging Face model is developed as part of the publication:<br>
|
| 601 |
+
<span class="sub">Open-Source Pre-Clinical Image Segmentation:</span>
|
| 602 |
+
<span class="sub">Mouse cardiac MRI datasets with a deep learning segmentation framework</span>
|
| 603 |
+
submitted to the Journal of Cardiovascular Magnetic Resonance
|
| 604 |
+
</h1>
|
| 605 |
+
</div>
|
| 606 |
+
<div class="text-wrap">
|
| 607 |
+
<p>In this paper, we present the first publicly-available pre-clinical cardiac MRI dataset, along with an open-source DL segmentation model (both available on GitHub:
|
| 608 |
+
<a href="https://github.com/mrphys/Open-Source_Pre-Clinical_Segmentation.git" target="_blank" rel="noopener">https://github.com/mrphys/Open-Source_Pre-Clinical_Segmentation.git</a>) and this web-based interface for easy deployment.</p>
|
| 609 |
+
<p>The dataset comprises complete cine short-axis cardiac MRI images from 130 mice with diverse phenotypes. It also contains expert manual segmentations of left ventricular (LV) blood pool and myocardium at end-diastole, end-systole, as well as additional timeframes with artefacts to improve robustness.</p>
|
| 610 |
+
<p>Using this resource, we developed an open-source DL segmentation model based on the UNet3+ architecture.</p>
|
| 611 |
+
<p>This Streamlit application consists of the inference model to provide an easy-to-use interface for our DL segmentation model, without the need for local installation. The application requires the complete SAX cine image data to be uploaded in NIfTI format, as a ZIP file using the simple file browser below.</p>
|
| 612 |
+
<p>Pre-processing and inference are then performed on all 2D images. The resulting blood-pool and myocardial volumes are combined across all slices at each timeframe and output to a .csv file. The blood-pool volumes are used to identify ED and ES, and these volumes are displayed as a GIF with the segmentations overlaid.</p>
|
| 613 |
+
</div>
|
| 614 |
+
</div>
|
| 615 |
+
</div>
|
| 616 |
+
""")
|
| 617 |
+
st.markdown(HERO_HTML, unsafe_allow_html=True)
|
| 618 |
+
|
| 619 |
+
# ---------- DATA UPLOAD (same column; perfect left alignment) ----------
|
| 620 |
+
st.markdown('<div class="content-wrap"><div class="measure-wrap" id="upload-wrap">', unsafe_allow_html=True)
|
| 621 |
st.markdown(
|
| 622 |
"""
|
| 623 |
+
<h2 style='margin-bottom:0.2rem;'>
|
| 624 |
+
Data Upload <span style='font-size:31px;'>📤</span>
|
| 625 |
+
</h2>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 626 |
""",
|
| 627 |
unsafe_allow_html=True
|
| 628 |
)
|
| 629 |
+
uploaded_zip = st.file_uploader(
|
| 630 |
+
"Upload ZIP of NIfTI files 🐭",
|
| 631 |
+
type="zip",
|
| 632 |
+
label_visibility="visible"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 633 |
)
|
| 634 |
+
st.markdown('</div></div>', unsafe_allow_html=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 635 |
|
| 636 |
+
# ---- Extract helper ----
|
| 637 |
def extract_zip(zip_path, extract_to):
|
| 638 |
os.makedirs(extract_to, exist_ok=True)
|
| 639 |
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
|
|
|
| 647 |
st.stop()
|
| 648 |
|
| 649 |
if st.button("Process Data"):
|
|
|
|
| 650 |
zip_label = uploaded_zip.name or "ZIP"
|
| 651 |
with st.spinner(f"Processing {zip_label}..."):
|
|
|
|
| 652 |
tmpdir = tempfile.mkdtemp()
|
| 653 |
zpath = os.path.join(tmpdir, uploaded_zip.name)
|
| 654 |
with open(zpath, "wb") as f:
|
|
|
|
| 671 |
model = keras.models.load_model(
|
| 672 |
MODEL_PATH,
|
| 673 |
custom_objects={
|
| 674 |
+
'focal_tversky_loss': None,
|
| 675 |
'dice_coef_no_bkg': None,
|
| 676 |
'ResizeAndConcatenate': ResizeAndConcatenate,
|
| 677 |
'dice_myo': None,
|
|
|
|
| 695 |
csv_path = write_all_in_one_csv(rows, per_frame_rows, CSV_DIR)
|
| 696 |
st.success(f"Processed {len(rows)} NIfTI file(s).")
|
| 697 |
csv_download_name = f"{Path(zip_label).stem}_Results.csv"
|
| 698 |
+
|
| 699 |
+
# -------- PASS BYTES (not a file object) --------
|
| 700 |
with open(csv_path, "rb") as f:
|
| 701 |
+
csv_bytes = f.read()
|
| 702 |
+
st.download_button(
|
| 703 |
+
label="Download CSV",
|
| 704 |
+
data=csv_bytes, # <-- bytes, best practice on HuggingFace/iframes
|
| 705 |
+
file_name=csv_download_name,
|
| 706 |
+
mime="text/csv",
|
| 707 |
+
key="dl_csv_results" # stable key helps HF embed
|
| 708 |
+
)
|
| 709 |
+
|
| 710 |
+
|
| 711 |
if __name__ == "__main__":
|
| 712 |
main()
|
| 713 |
+
|