import gradio as gr
import os
import json
import numpy as np
import requests
from PIL import Image
import uuid
import glob
import time
# Create directories for examples and temporary files
os.makedirs("examples", exist_ok=True)
os.makedirs("temp", exist_ok=True)
os.makedirs("static", exist_ok=True) # 'static' directory is not used by Ketcher from CDN
os.environ["GRADIO_TEMP_DIR"] = os.path.abspath("temp")
# Sample example images
EXAMPLE_IMAGES = [
"examples/9213587.png",
"examples/4056723.png",
"examples/7632980.png",
"examples/1890451.png",
"examples/3401765.png",
"examples/8572109.png",
]
url = os.getenv("URL")
headers = {
"Authorization": os.getenv("KEY")
}
# Ensure example images exist, or create placeholders
for img_path_rel in EXAMPLE_IMAGES:
# Make paths absolute for robust checking and placeholder creation
img_path_abs = os.path.abspath(img_path_rel)
if not os.path.exists(img_path_abs):
print(f"Warning: Example image {img_path_abs} not found. Creating a placeholder.")
try:
# Ensure the 'examples' directory exists before saving placeholder
os.makedirs(os.path.dirname(img_path_abs), exist_ok=True)
placeholder_img = Image.new('RGB', (200, 200), color='lightgrey') # Increased size
# Add text to placeholder
from PIL import ImageDraw
draw = ImageDraw.Draw(placeholder_img)
draw.text((10, 10), os.path.basename(img_path_rel) + "\n(Placeholder)", fill=(0, 0, 0))
placeholder_img.save(img_path_abs)
except Exception as e:
print(f"Could not create placeholder image {img_path_abs}: {e}")
# HTML for Ketcher container - Simplified, static Ketcher
viewer_html = """
"""
# HTML for sidebar with icons - without JavaScript functions
sidebar_html = """
"""
# JavaScript to initialize sidebar functionality
init_sidebar_js = """
function() {
// Global variables for sidebar
window.currentContent = "";
window.currentContentType = "";
// Global variables for multiple molecules
window.currentMolecules = [];
window.currentActiveTab = 0;
// Define sidebar functions in global scope
window.showSidebar = function(content, contentType) {
const sidebar = document.getElementById('sidebar-container');
const contentArea = document.getElementById('content-area');
const sidebarTitle = document.getElementById('sidebar-title');
// Update content and type
window.currentContent = content;
window.currentContentType = contentType;
// Set title based on content type
sidebarTitle.textContent = contentType === 'smiles' ? 'SMILES' : 'Molfile';
// Format and display content
let formattedContent = content;
if (contentType === 'molfile') {
// For Molfile, preserve line breaks and formatting
formattedContent = '' + content.replace(//g, '>') + '
';
} else {
// For SMILES, word-wrap but don't add pre tags
formattedContent = '' + content.replace(//g, '>') + '
';
}
contentArea.innerHTML = formattedContent;
// Show sidebar with animation
sidebar.style.display = 'block';
setTimeout(() => {
sidebar.style.right = '0';
}, 10);
};
window.hideSidebar = function() {
const sidebar = document.getElementById('sidebar-container');
sidebar.style.right = '-350px';
setTimeout(() => {
sidebar.style.display = 'none';
}, 300);
};
// Function to switch molecules in Ketcher
window.switchToMolecule = function(moleculeIndex) {
if (!window.currentMolecules || moleculeIndex >= window.currentMolecules.length) {
console.error('Invalid molecule index or no molecules available');
return;
}
const molecule = window.currentMolecules[moleculeIndex];
if (window.ketcher && typeof window.ketcher.setMolecule === 'function') {
try {
window.ketcher.setMolecule(molecule.molfile);
window.currentActiveTab = moleculeIndex;
// console.log(`Switched to molecule ${moleculeIndex + 1}: ${molecule.smiles}`);
// Update active tab styling
const tabButtons = document.querySelectorAll('.mol-tab-btn');
tabButtons.forEach((btn, index) => {
if (index === moleculeIndex) {
btn.classList.add('active');
btn.style.backgroundColor = '#003366';
btn.style.color = 'white';
} else {
btn.classList.remove('active');
btn.style.backgroundColor = '#f5f5f5';
btn.style.color = '#333';
}
});
} catch (e) {
console.error('Error setting molecule in Ketcher:', e);
}
} else {
console.error('Ketcher not ready or setMolecule function not available');
}
};
// Improved copy to clipboard function with fallback
window.copyToClipboard = function() {
if (!window.currentContent) return;
const copyBtn = document.getElementById('copy-btn-s');
const showSuccess = function() {
copyBtn.innerHTML = '';
setTimeout(() => {
copyBtn.innerHTML = '';
}, 2000);
};
// Try using the Clipboard API first
if (navigator && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
navigator.clipboard.writeText(window.currentContent)
.then(showSuccess)
.catch(function(err) {
console.warn('Clipboard API failed:', err);
// Fall back to the textarea method
fallbackCopyTextToClipboard();
});
} else {
// Fallback for browsers that don't support the Clipboard API
fallbackCopyTextToClipboard();
}
// Fallback copy method using textarea and document.execCommand
function fallbackCopyTextToClipboard() {
try {
const textarea = document.getElementById('copy-textarea');
textarea.value = window.currentContent;
textarea.focus();
textarea.select();
const successful = document.execCommand('copy');
if (successful) {
console.log('Fallback copy successful');
showSuccess();
} else {
console.warn('Fallback copy command was unsuccessful');
alert('Copy failed. Please try selecting and copying the text manually.');
}
} catch (err) {
console.error('Fallback copy failed:', err);
alert('Copy failed. Please try selecting and copying the text manually.');
}
}
};
window.downloadContent = function() {
if (window.currentContent) {
const extension = window.currentContentType === 'smiles' ? '.smi' : '.mol';
const filename = 'molecule' + extension;
const blob = new Blob([window.currentContent], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
};
// Set up event listeners
document.addEventListener('DOMContentLoaded', function() {
// Close sidebar button
const closeBtn = document.getElementById('close-sidebar');
if (closeBtn) {
closeBtn.addEventListener('click', window.hideSidebar);
}
// Copy button
const copyBtn = document.getElementById('copy-btn-s');
if (copyBtn) {
copyBtn.addEventListener('click', window.copyToClipboard);
}
// Download button
const downloadBtn = document.getElementById('download-btn');
if (downloadBtn) {
downloadBtn.addEventListener('click', window.downloadContent);
}
});
// Initialize event listeners immediately if DOM is already loaded
if (document.readyState === 'complete' || document.readyState === 'interactive') {
const closeBtn = document.getElementById('close-sidebar');
if (closeBtn) {
closeBtn.addEventListener('click', window.hideSidebar);
}
const copyBtn = document.getElementById('copy-btn-s');
if (copyBtn) {
copyBtn.addEventListener('click', window.copyToClipboard);
}
const downloadBtn = document.getElementById('download-btn');
if (downloadBtn) {
downloadBtn.addEventListener('click', window.downloadContent);
}
}
console.log("Sidebar functionality initialized");
}
"""
# JavaScript to load Ketcher (simplified, static version)
load_ketcher_js = """
async () => {
var loadingDiv = document.getElementById('loading-ketcher');
var ketcherRootDiv = document.getElementById('root');
loadingDiv.style.display = 'flex';
ketcherRootDiv.style.display = 'none';
let cssUrl = "https://huggingface.co/datasets/Sunin/ketcher-3.2.0/raw/main/ketcher_standalone_3.2.0/static/css/main.aafe9c71.css";
fetch(cssUrl)
.then(res => res.text())
.then(text => {
const style = document.createElement('style');
style.textContent = text;
document.head.appendChild(style);
}).catch(err => console.error("Failed to load Ketcher CSS:", err));
let jsUrl = "https://huggingface.co/datasets/Sunin/ketcher-3.2.0/resolve/main/ketcher_standalone_3.2.0/static/js/main.7edc2752.js";
fetch(jsUrl)
.then(res => res.text())
.then(text => {
const script = document.createElement('script');
script.src = URL.createObjectURL(new Blob([text], { type: 'application/javascript' }));
script.onload = () => {
function checkKetcherReady() {
if (window.ketcher && typeof window.ketcher.setMolecule === 'function') {
loadingDiv.style.display = 'none';
ketcherRootDiv.style.display = 'block';
console.log("Ketcher loaded and initialized.");
} else {
setTimeout(checkKetcherReady, 100);
}
}
checkKetcherReady();
};
script.onerror = () => {
console.error("Failed to load Ketcher JS.");
loadingDiv.innerHTML = "Failed to load SMILES editor.
";
};
document.head.appendChild(script);
}).catch(err => {
console.error("Failed to fetch Ketcher JS:", err);
loadingDiv.innerHTML = "Failed to fetch SMILES editor assets.
";
});
}
"""
# NEW: JavaScript to initialize clipboard paste functionality
init_clipboard_paste_js = """
function() {
console.log('Initializing clipboard paste functionality');
// Function to handle pasted image
window.handlePastedImage = function(file) {
console.log('Processing pasted image file:', file);
// Create a FileReader to read the image
const reader = new FileReader();
reader.onload = function(e) {
const img = new Image();
img.onload = function() {
// Create a canvas to convert the image to the format Gradio expects
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = img.width;
canvas.height = img.height;
// Set crossOrigin to avoid CORS issues
img.crossOrigin = 'anonymous';
// Draw the image on canvas
ctx.drawImage(img, 0, 0);
// Convert canvas to blob
canvas.toBlob(function(blob) {
// Create a File object from the blob
const pastedFile = new File([blob], 'pasted-image.png', { type: 'image/png' });
// Find the Gradio image input element
const imageInput = document.querySelector('#molecule-image input[type="file"]');
if (imageInput) {
// Create a new FileList containing our pasted file
const dt = new DataTransfer();
dt.items.add(pastedFile);
imageInput.files = dt.files;
// Trigger the change event to notify Gradio
const changeEvent = new Event('change', { bubbles: true });
imageInput.dispatchEvent(changeEvent);
console.log('Pasted image successfully uploaded to Gradio');
} else {
console.error('Could not find Gradio image input element');
}
}, 'image/png');
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
};
// Add paste event listener to the document
document.addEventListener('paste', function(event) {
console.log('Paste event detected');
const items = (event.clipboardData || event.originalEvent.clipboardData).items;
if (items.length > 0) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
// Check if the item is an image
if (item.type.indexOf('image') === 0) {
console.log('Image found in clipboard');
const file = item.getAsFile();
if (file) {
window.handlePastedImage(file);
event.preventDefault(); // Prevent default paste behavior
break; // Only process the first image
}
}
}
}
});
console.log('Clipboard paste functionality initialized successfully');
}
"""
# JavaScript to update molecules and render first one
update_molecules_js = """
async (moleculesJson) => {
function updateMolecules() {
if (typeof window.ketcher !== 'undefined' && window.ketcher && typeof window.ketcher.setMolecule === 'function') {
try {
if (moleculesJson && moleculesJson.trim() !== "") {
const molecules = JSON.parse(moleculesJson);
window.currentMolecules = molecules;
// Render the first molecule immediately
if (molecules.length > 0 && molecules[0].molfile) {
window.ketcher.setMolecule(molecules[0].molfile);
window.currentActiveTab = 0;
// console.log(`Loaded first molecule: ${molecules[0].smiles}`);
}
} else {
// Clear Ketcher if no molecules
window.ketcher.setMolecule('');
window.currentMolecules = [];
console.log('Cleared Ketcher - no molecules provided');
}
} catch (e) {
console.error('Error updating molecules:', e);
window.currentMolecules = [];
}
} else {
console.log('Ketcher not ready yet, retrying...');
setTimeout(updateMolecules, 250);
}
}
updateMolecules();
}
"""
# Modified JavaScript for getting SMILES from Ketcher, processing with sma2smi, and showing in sidebar
get_smiles_js = """
async () => {
try {
// Get SMILES from Ketcher
const ketcher_smiles = await ketcher.getSmiles();
// console.log("SMILES from Ketcher:", ketcher_smiles);
if (ketcher_smiles && ketcher_smiles.trim()) {
// Send the SMILES to Python for processing with sma2smi
// This will be handled by the Python function
return ketcher_smiles;
} else {
alert("No molecule structure found. Please draw a structure first.");
return "";
}
} catch (error) {
console.error("Error getting SMILES:", error);
alert("Error getting SMILES: " + error.message);
return "";
}
}
"""
# JavaScript for getting Molfile from Ketcher and showing in sidebar
get_molfile_js = """
async () => {
try {
const molfile = await ketcher.getMolfile();
// console.log("Molfile from Ketcher (first 100 chars):", molfile ? molfile.substring(0, 100) + "..." : "None");
if (molfile && molfile.trim()) {
// Show the sidebar with Molfile content
window.showSidebar(molfile, 'molfile');
return molfile;
} else {
alert("No molecule structure found. Please draw a structure first.");
return "";
}
} catch (error) {
console.error("Error getting Molfile:", error);
alert("Error getting Molfile: " + error.message);
return "";
}
}
"""
# JavaScript to hide running indicator (add debug logging)
hide_running_indicator_js = """
function() {
console.log('Attempting to hide running indicator');
const indicator = document.getElementById('running-indicator');
if (indicator) {
indicator.style.display = 'none';
console.log('Indicator hidden successfully');
} else {
console.warn('Running indicator element not found');
}
}
"""
# Main application function
def create_app():
css = """
#app { background: #F5F5F5; border-radius: 10px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); padding: 20px; }
#header { color: #333; text-align: center; margin-bottom: 20px; }
#main-container { display: flex; flex-direction: row; gap: 20px; background-color: white; border-radius: 8px; padding: 15px; }
#image-container, #ketcher-column { flex: 1; min-height: 600px; border: 1px dashed #ccc; border-radius: 8px; padding:10px; display: flex; flex-direction: column; justify-content: space-between;}
#ketcher-display-wrapper { width: 100%; height: 580px; /* Fixed height for ketcher root */ }
#button-container { display: flex; gap: 10px; margin-top: 10px; justify-content: center;}
.btn {
background-color: #003366; /* 深化按钮背景色,增强对比度 */
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
cursor: pointer;
transition: background-color 0.3s;
font-weight: bold; /* 加粗按钮文字 */
font-size: 1.05em; /* 增大按钮文字 */
}
.btn:hover { background-color: #002850; }
.btn:disabled { background-color: #cccccc; cursor: not-allowed; }
/* Molecule tab buttons */
.mol-tab-btn {
background-color: #f5f5f5;
color: #333;
border: 1px solid #ddd;
border-radius: 4px;
padding: 6px 16px;
cursor: pointer;
transition: all 0.3s;
font-weight: bold;
font-size: 1em;
margin-right: 5px;
}
.mol-tab-btn:hover {
background-color: #e5e5e5;
}
.mol-tab-btn.active {
background-color: #003366;
color: white;
}
#smiles-btn:hover::after {
content: "Get SMILES"; /* 显示的tooltip文本 */
position: absolute;
bottom: 125%; /* 定位在按钮上方 */
left: 10%;
transform: translateX(-50%); /* 居中对齐 */
background-color: #333;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.9em;
white-space: nowrap;
z-index: 1;
}
#molfile-btn:hover::after {
content: "Get Molfile"; /* 显示的tooltip文本 */
position: absolute;
bottom: 125%; /* 定位在按钮上方 */
left: 90%;
transform: translateX(-50%); /* 居中对齐 */
background-color: #333;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.9em;
white-space: nowrap;
z-index: 1;
}
#examples-section { margin-top: 20px; background-color: white; border-radius: 8px; padding: 10px; }
#examples-section h3 { margin: 0 0 10px 0; font-size: 1.2em; text-align: center;}
.gr-gallery { width: 100% !important; padding: 0 !important; margin: 0 !important; display: flex !important; justify-content: center !important; flex-wrap: wrap !important; gap: 10px !important; }
.gr-gallery img { width: 133px !important; height: 133px !important; object-fit: contain !important; cursor: pointer !important; transition: transform 0.2s !important; border: 1px solid #eee !important; border-radius: 4px !important; }
.gr-gallery img:hover { transform: scale(1.05) !important; }
#molecule-image { min-height: 600px !important; display:flex; align-items:center; justify-content:center; }
#molecule-image div[data-testid="image"] { width:100% !important; height:100% !important;} /* Ensure inner div also takes full size */
#molecule-image img { object-fit: contain !important; max-width: 100%; max-height: 100%;} /* Ensure image scales within its container */
#root { width: 100%; height: 100%; }
/* NEW: Enhanced styling for image upload area with hints */
#molecule-image .image-container {
position: relative;
width: 100%;
height: 100%;
min-height: 500px;
border: 2px dashed #ccc;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background-color: #fafafa;
transition: border-color 0.3s, background-color 0.3s;
}
#molecule-image .image-container:hover {
border-color: #003366;
background-color: #f0f8ff;
}
#molecule-image .upload-hint {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: #666;
font-size: 16px;
pointer-events: none;
z-index: 1;
}
#molecule-image .upload-hint .hint-icon {
font-size: 48px;
margin-bottom: 16px;
color: #003366;
}
#molecule-image .upload-hint .hint-text {
line-height: 1.5;
}
#molecule-image .upload-hint .hint-shortcut {
font-weight: bold;
color: #003366;
margin-top: 8px;
font-size: 14px;
}
/* Hide hint when image is present */
#molecule-image:has(img) .upload-hint {
display: none;
}
/* 修改1: 移除图片展示界面和start button之间的空白 */
#image-container {
padding-bottom: 0;
}
#image-container #button-container {
margin-top: 0;
}
#processed-smiles-container {
display: flex;
width: 100%; /* 使用100%宽度与ketcher界面对齐 */
margin-top: 5px;
gap: 2px; /* 进一步减小间距到2px */
align-items: stretch; /* 确保两个元素高度一致 */
box-sizing: border-box; /* 确保宽度计算包含内边距和边框 */
}
#processed-smiles-box {
width: 90%; /* 占据更多空间 */
min-width: 90%; /* 确保至少占据90%的空间 */
max-width: 90%; /* 限制最大宽度为90% */
box-sizing: border-box; /* 确保内边距和边框包含在宽度内 */
border: 1px solid #ddd;
border-radius: 4px;
padding: 8px 4px;
font-family: monospace;
min-height: 40px;
background-color: #f9f9f9;
overflow-x: auto;
white-space: nowrap;
margin: 0; /* 移除可能的额外间距 */
}
#copy-btn {
width: 10%; /* 占据更少空间 */
min-width: 10%; /* 确保按钮可见 */
max-width: 10%; /* 限制最大宽度为10% */
box-sizing: border-box; /* 确保内边距和边框包含在宽度内 */
border-radius: 4px;
border: none; /* 移除边框 */
background-color: #003366; /* 深化按钮背景色,增强对比度 */
color: white;
padding: 0;
cursor: pointer;
transition: background-color 0.3s;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3em; /* 优化图标大小以提高清晰度 */
margin: 0; /* 移除可能的额外间距 */
position: relative; /* 启用相对定位以支持tooltip */
}
#copy-btn:hover {
background-color: #002850;
}
#copy-btn:hover::after {
content: "Copy to clipboard"; /* 显示的tooltip文本 */
position: absolute;
bottom: 125%; /* 定位在按钮上方 */
left: 50%;
transform: translateX(-50%); /* 居中对齐 */
background-color: #333;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.9em;
white-space: nowrap;
z-index: 1;
}
#copy-btn:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
#copy-btn:disabled::after {
display: none; /* 禁用时隐藏tooltip */
}
/* Sidebar styles */
#sidebar-container {
position: fixed;
top: 0;
right: -350px; /* Or your logic for showing/hiding */
width: 350px;
height: 100%;
background-color: white;
box-shadow: -2px 0 5px rgba(0, 0, 0, 0.1);
z-index: 1000;
transition: right 0.3s ease;
/* overflow: auto; /* Consider changing to overflow: hidden if content-area is the sole scroller */
padding: 20px;
/* Add these flex properties */
display: flex;
flex-direction: column;
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px; /* This will create space below the header, above the actions */
border-bottom: 1px solid #eee;
padding-bottom: 10px;
/* flex-shrink: 0; /* This is default, header won't shrink */
}
/* Styles for the MOVED actions container */
.sidebar-actions {
display: flex;
justify-content: flex-end; /* Keeps buttons to the right */
gap: 10px;
padding: 10px 0; /* Existing padding, provides space above/below buttons */
/* Add margin-bottom for spacing between buttons and content-area */
margin-bottom: 15px; /* Adjust as needed */
/* flex-shrink: 0; /* This is default, actions won't shrink */
}
.sidebar-content {
display: flex;
flex-direction: column;
/* REMOVE: height: calc(100% - 60px); */
/* ADD these flex properties to make it take remaining space */
flex: 1;
min-height: 0; /* Important for flex children that need to shrink and scroll internally */
/* overflow: hidden; /* If you want #content-area to be the only scroller within this */
}
#content-area {
flex: 1; /* Will take available space in .sidebar-content */
overflow: auto; /* Allows scrolling for the content itself */
background-color: #f9f9f9;
border: 1px solid #eee;
border-radius: 4px;
padding: 10px;
/* REMOVE: margin-bottom: 10px; /* No longer needed as actions are not below */
font-family: monospace;
}
.icon-button {
background: none;
border: none;
cursor: pointer;
padding: 5px;
border-radius: 4px;
color: #004080;
transition: background-color 0.2s;
}
.icon-button:hover {
background-color: #f0f0f0;
}
pre {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
}
/* Molecule tabs container */
#molecule-tabs-container {
margin-bottom: 0px;
padding: 0px 0;
border-bottom: 1px solid #eee;
}
"""
with gr.Blocks(css=css, title="CLIP-OCSR") as app:
uploaded_image_state = gr.State(None)
current_smiles_state = gr.State(None) # Will store the SMILES from OCSR or Ketcher
current_molfile_path_state = gr.State(None)
session_id_state = gr.State(value=lambda: str(uuid.uuid4()))
# NEW: State to store multiple molecules
multiple_molecules_state = gr.State([])
# Hidden components for JavaScript communication
molecules_json_trigger = gr.Textbox(visible=False, label="Molecules JSON for JS")
smiles_data = gr.Textbox(visible=False)
molfile_data = gr.Textbox(visible=False)
# hide_indicator_html_trigger = gr.HTML()
with gr.Column(elem_id="app"):
gr.HTML(sidebar_html)
with gr.Row(elem_id="header"):
gr.HTML("CLIP-OCSR: CLIP-empowered Optical Chemical Structure Recognition
Online tool for chemical structure extraction
")
with gr.Row(elem_id="main-container"):
with gr.Column(elem_id="image-container"):
image_display = gr.Image(
type="numpy",
label="Molecule Image",
elem_id="molecule-image",
height=500,
interactive=True,
show_label=False,
show_download_button=True,
show_share_button=False,
placeholder="Click to upload, drag & drop, or press Ctrl+V to paste an image"
)
gr.HTML("""
""")
with gr.Row(elem_id="button-container"):
start_btn = gr.Button("Start Recognition", elem_id="start-btn", variant="primary", elem_classes="btn")
with gr.Column(elem_id="ketcher-column"):
# NEW: Molecule tabs area
molecule_tabs_area = gr.HTML(value="", elem_id="molecule-tabs-container")
with gr.Column(elem_id="ketcher-display-wrapper"):
ketcher_html_display = gr.HTML(value=viewer_html)
with gr.Row(elem_id="button-container"):
smiles_btn = gr.Button("SMILES", elem_id="smiles-btn", elem_classes="btn")
molfile_btn = gr.Button("MOLfile", elem_id="molfile-btn", elem_classes="btn")
with gr.Row(elem_id="processed-smiles-container"):
processed_smiles_output = gr.HTML(
value=""
)
copy_btn = gr.Button(
"📋",
elem_id="copy-btn",
)
with gr.Column(elem_id="examples-section"):
gr.HTML("Examples
")
examples_gallery = gr.Gallery(
value=[(img_path, os.path.basename(img_path)) for img_path in EXAMPLE_IMAGES],
label="",
columns=6,
height="200px",
object_fit="contain",
elem_id="examples-gallery"
)
def handle_example_selection(evt: gr.SelectData):
print(f"Example selection event triggered. Index: {evt.index}")
if evt.index < len(EXAMPLE_IMAGES):
file_path = EXAMPLE_IMAGES[evt.index]
print(f"Selected example image: {file_path}")
if os.path.exists(file_path):
try:
img = Image.open(file_path).convert("RGB")
numpy_image = np.array(img)
print(f"Successfully loaded example image. Shape: {numpy_image.shape}")
return numpy_image, numpy_image, None, [], "", ""
except Exception as e:
print(f"Error loading example image: {e}")
gr.Error(f"Failed to load example image: {e}")
else:
print(f"Example image file not found: {file_path}")
gr.Error(f"Example image file not found: {file_path}")
else:
print(f"Invalid example index: {evt.index}")
gr.Error("Invalid example selection")
return gr.update(), gr.update(), None, [], "", ""
def handle_image_upload(image_data, current_session_id):
print(f"Image upload triggered for session: {current_session_id}")
print(f"Type of received image_data: {type(image_data)}")
if isinstance(image_data, np.ndarray):
print(f"Image uploaded as NumPy array, shape: {image_data.shape}")
return image_data, image_data, None, [], "", ""
else:
print(f"Received unexpected image data type: {type(image_data)}")
gr.Warning("Could not process the uploaded image. Please try again.")
return gr.update(), gr.update(), None, [], "", ""
def handle_start_recognition(img_data_from_state, current_session_id):
if img_data_from_state is None:
gr.Warning("No image provided for recognition! Please upload or select an example image first.")
return None, [], "", ""
print(f"Starting recognition for session: {current_session_id}")
pil_image = Image.fromarray(img_data_from_state.astype('uint8'), 'RGB')
temp_dir_abs = os.path.abspath("temp")
os.makedirs(temp_dir_abs, exist_ok=True)
temp_img_filename = f"input_{current_session_id}_{uuid.uuid4().hex[:8]}.png"
temp_img_path = os.path.join(temp_dir_abs, temp_img_filename)
pil_image.save(temp_img_path)
# print(f"Temporary image for prediction saved to: {temp_img_path}")
predicted_smiles_for_state = ""
molecules_list = []
molecules_json = ""
tabs_html = ""
files = {'image': open(temp_img_path, 'rb')}
try:
response1 = requests.post(f"{url}/predict", files=files, headers=headers)
response1.raise_for_status() # Raise an error for bad status codes
processed_smiles = response1.json().get('predicted_smiles')
# print(f"Raw Predicted SMILES from OCSR: {raw_smiles}")
if processed_smiles:
# NEW: Check if processed_smiles contains "$" for multiple SMILES
if "$" in processed_smiles:
try:
response4 = requests.post(f"{url}/rearrange", data={"smiles_v": processed_smiles}, headers=headers)
response4.raise_for_status() # Raise an error for bad status codes
smi_set = response4.json().get('smi_set')
if isinstance(smi_set, list) and len(smi_set) > 1:
# Multiple SMILES case
for i, smiles in enumerate(smi_set):
response2 = requests.post(f"{url}/smi2mol", data={"smiles": smiles}, headers=headers)
response2.raise_for_status() # Raise an error for bad status codes
mol_block = response2.json().get('mol_block')
if mol_block:
molecules_list.append({
'smiles': smiles,
'molfile': mol_block,
'index': i
})
# print(f"Molecule {i+1}: {smiles}")
if molecules_list:
# Use the first molecule for the main display
predicted_smiles_for_state = molecules_list[0]['smiles']
molecules_json = json.dumps(molecules_list)
# Create tabs HTML
tabs_buttons = []
for i, mol in enumerate(molecules_list):
active_class = "active" if i == 0 else ""
tabs_buttons.append(f'')
tabs_html = f'{"".join(tabs_buttons)}
'
print(f"Created {len(molecules_list)} molecules for tabbed interface")
else:
warning_msg = "No valid molecules could be generated from the multiple SMILES"
gr.Warning(warning_msg)
print(warning_msg)
else:
# rv returned single SMILES or empty
single_smiles = smi_set[0] if isinstance(smi_set, list) and len(smi_set) > 0 else processed_smiles
predicted_smiles_for_state = single_smiles
response2 = requests.post(f"{url}/smi2mol", data={"smiles": single_smiles}, headers=headers)
response2.raise_for_status() # Raise an error for bad status codes
mol_block = response2.json().get('mol_block')
if mol_block:
molecules_list = [{'smiles': single_smiles, 'molfile': mol_block, 'index': 0}]
molecules_json = json.dumps(molecules_list)
tabs_html = ''
else:
warning_msg = f"Invalid SMILES ('{single_smiles}') after rv processing"
gr.Warning(warning_msg)
print(warning_msg)
except Exception as e:
print(f"Error processing multiple SMILES with rv function: {e}")
# Fallback to single SMILES processing
predicted_smiles_for_state = processed_smiles
response2 = requests.post(f"{url}/smi2mol", data={"smiles": processed_smiles}, headers=headers)
response2.raise_for_status() # Raise an error for bad status codes
mol_block = response2.json().get('mol_block')
if mol_block:
molecules_list = [{'smiles': processed_smiles, 'molfile': mol_block, 'index': 0}]
molecules_json = json.dumps(molecules_list)
tabs_html = ''
else:
# Single SMILES case (no "$")
predicted_smiles_for_state = processed_smiles
response2 = requests.post(f"{url}/smi2mol", data={"smiles": processed_smiles}, headers=headers)
response2.raise_for_status() # Raise an error for bad status codes
mol_block = response2.json().get('mol_block')
if mol_block:
molecules_list = [{'smiles': processed_smiles, 'molfile': mol_block, 'index': 0}]
molecules_json = json.dumps(molecules_list)
tabs_html = ''
else:
warning_msg = f"Invalid SMILES ('{processed_smiles}') after processing or from smi2mol, cannot create Molfile or reliably show in Ketcher."
gr.Warning(warning_msg)
print(warning_msg)
molecules_json = ""
predicted_smiles_for_state = f": {warning_msg}"
else:
info_msg = "No SMILES string was predicted by the model."
gr.Info(info_msg)
print(info_msg)
molecules_json = ""
except Exception as e:
error_msg = f"Error during OCSR prediction or Molfile generation: {e}"
print(error_msg)
gr.Error(f"An error occurred during processing: {str(e)[:100]}...")
molecules_json = ""
predicted_smiles_for_state = f"Error: {str(e)[:100]}"
finally:
if os.path.exists(temp_img_path):
try:
os.remove(temp_img_path)
# print(f"Removed temporary image: {temp_img_path}")
except Exception as e_rem:
print(f"Error removing temporary image {temp_img_path}: {e_rem}")
return predicted_smiles_for_state, molecules_list, molecules_json, tabs_html
def process_smiles_and_show(ketcher_smiles):
if not ketcher_smiles or not ketcher_smiles.strip():
gr.Warning("No SMILES structure available. Please draw a structure first.")
return gr.update(value="")
try:
# print(f"Processing SMILES with sma2smi: {ketcher_smiles}")
response3 = requests.post(f"{url}/sma2smi", data={"sma": ketcher_smiles}, headers=headers)
response3.raise_for_status() # Raise an error for bad status codes
processed_smiles = response3.json().get('smi')
# print(f"Processed SMILES result: {processed_smiles}")
escaped_smiles = processed_smiles.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"').replace("'", ''')
return gr.update(value=f"{escaped_smiles}
")
except Exception as e:
error_msg = f"Error processing SMILES with sma2smi: {e}"
print(error_msg)
gr.Error(f"An error occurred during SMILES processing: {str(e)[:100]}...")
return gr.update(value="Error processing SMILES
")
# MODIFIED SECTION START
# Define the combined workflow function
def combined_recognition_workflow(img_data_from_state, current_session_id):
empty = gr.HTML(value="") # 负责清空processed-smiles-box里的内容
predicted_smiles, molecules_list, molecules_json, tabs_html = handle_start_recognition(img_data_from_state, current_session_id)
return predicted_smiles, molecules_list, molecules_json, tabs_html, empty #, ""
image_display.upload(
fn=handle_image_upload,
inputs=[image_display, session_id_state],
outputs=[image_display, uploaded_image_state, current_smiles_state, multiple_molecules_state, molecules_json_trigger, molecule_tabs_area],
show_progress="upload"
)
examples_gallery.select(
fn=handle_example_selection,
inputs=[],
outputs=[image_display, uploaded_image_state, current_smiles_state, multiple_molecules_state, molecules_json_trigger, molecule_tabs_area]
)
# Replace the .then() chain with a single .click() call
start_btn.click(
fn=combined_recognition_workflow,
inputs=[uploaded_image_state, session_id_state],
outputs=[
current_smiles_state, # Updated by combined_recognition_workflow's 1st return value
multiple_molecules_state, # Updated by combined_recognition_workflow's 2nd return value
molecules_json_trigger, # Updated by combined_recognition_workflow's 3rd return value
molecule_tabs_area, # Updated by combined_recognition_workflow's 4th return value
processed_smiles_output, # 负责清空processed-smiles-box里的内容
# hide_indicator_html_trigger, # 很重要,需要有这个变量才能展示识别的progress,现在mol tab区域显示progress,就不用这个了
],
)
# MODIFIED SECTION END
# Update molecules in Ketcher when molecules_json_trigger changes
molecules_json_trigger.change(
fn=None,
inputs=[molecules_json_trigger],
outputs=[],
js=update_molecules_js
)
smiles_btn.click(
fn=process_smiles_and_show,
inputs=[smiles_data],
outputs=[processed_smiles_output],
js="""
async function() {
try {
console.log('Fetching SMILES from Ketcher for main display');
const ketcher = window.ketcher;
if (!ketcher || typeof ketcher.getSmiles !== 'function') {
alert('Ketcher is not ready. Please wait for it to load.');
return '';
}
const smiles = await ketcher.getSmiles();
// console.log('SMILES from Ketcher (for main display):', smiles);
if (!smiles || !smiles.trim()) {
alert('No molecule structure found in Ketcher. Please draw a structure first.');
return '';
}
return smiles;
} catch (e) {
console.error('Error fetching SMILES from Ketcher (for main display):', e);
alert('Error fetching SMILES: ' + e.message);
return '';
}
}
"""
)
molfile_btn.click(
fn=None,
inputs=[],
outputs=[molfile_data],
js=get_molfile_js
)
copy_btn.click(
fn=None,
inputs=[],
outputs=[],
js="""
function() {
const smilesBox = document.getElementById('processed-smiles-box');
const textToCopy = smilesBox ? smilesBox.textContent : null;
const mainCopyButtonElement = document.getElementById('copy-btn');
if (!mainCopyButtonElement) {
console.error('Copy button element with id "copy-btn" not found.');
return;
}
if (textToCopy && textToCopy.trim() !== "") {
const originalButtonText = mainCopyButtonElement.textContent;
if (navigator && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
navigator.clipboard.writeText(textToCopy)
.then(() => {
mainCopyButtonElement.disabled = true;
setTimeout(() => {
mainCopyButtonElement.textContent = originalButtonText;
mainCopyButtonElement.disabled = false;
}, 2000);
// console.log('Text copied to clipboard:', textToCopy);
})
.catch(function(clipboardErr) {
console.warn('Clipboard API failed:', clipboardErr);
fallbackCopyTextToClipboard();
});
} else {
console.warn('navigator.clipboard.writeText not available. Using fallback.');
fallbackCopyTextToClipboard();
}
function fallbackCopyTextToClipboard() {
let textarea = document.getElementById('copy-textarea');
let tempTextareaCreated = false;
if (!textarea) {
textarea = document.createElement('textarea');
textarea.id = 'copy-textarea-temp-main-fallback';
textarea.style.position = 'absolute';
textarea.style.left = '-9999px';
textarea.style.top = '0';
textarea.setAttribute('aria-hidden', 'true');
document.body.appendChild(textarea);
tempTextareaCreated = true;
}
textarea.value = textToCopy;
textarea.focus();
textarea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
console.log('Fallback copy successful');
mainCopyButtonElement.textContent = 'Copied!';
mainCopyButtonElement.disabled = true;
setTimeout(() => {
mainCopyButtonElement.textContent = originalButtonText;
mainCopyButtonElement.disabled = false;
}, 2000);
} else {
console.warn('Fallback copy command was unsuccessful');
alert('Copy failed. Please try selecting and copying the text manually.');
}
} catch (err) {
console.error('Error during fallback copy:', err);
alert('Copy failed. Please try selecting and copying the text manually.');
} finally {
if (tempTextareaCreated && textarea.parentNode) {
textarea.parentNode.removeChild(textarea);
}
if (document.activeElement === textarea) {
textarea.blur();
} else if (window.getSelection) {
if (window.getSelection().empty) {
window.getSelection().empty();
} else if (window.getSelection().removeAllRanges) {
window.getSelection().removeAllRanges();
}
} else if (document.selection) {
document.selection.empty();
}
}
}
} else {
alert('No SMILES data to copy or data is empty.');
}
}
"""
)
app.load(
fn=None,
inputs=None,
outputs=None,
js=init_sidebar_js
)
app.load(
fn=None,
inputs=None,
outputs=None,
js=load_ketcher_js
)
# NEW: Initialize clipboard paste functionality
app.load(
fn=None,
inputs=None,
outputs=None,
js=init_clipboard_paste_js
)
return app
if __name__ == "__main__":
current_time_sec = time.time()
one_hour_ago_sec = current_time_sec - 3600
temp_dir_abs = os.path.abspath("temp")
if os.path.isdir(temp_dir_abs):
for old_file_pattern in ["*.mol", "*.png", "*.smi"]:
for old_file_path in glob.glob(os.path.join(temp_dir_abs, old_file_pattern)):
try:
if os.path.getmtime(old_file_path) < one_hour_ago_sec:
os.remove(old_file_path)
print(f"Removed old temp file: {old_file_path}")
except OSError as e:
print(f"Error removing old temp file {old_file_path}: {e}")
else:
print(f"Temporary directory '{temp_dir_abs}' does not exist. Skipping cleanup.")
gradio_app = create_app()
try:
gradio_app.launch(debug=True)
except Exception as e:
print(f"Failed to launch Gradio app: {e}")
print("This might be due to a port conflict or other server setup issues.")