| import gradio as gr |
| import os |
| import json |
| import numpy as np |
| import requests |
| from PIL import Image |
| import uuid |
| import glob |
| import time |
|
|
| |
| os.makedirs("examples", exist_ok=True) |
| os.makedirs("temp", exist_ok=True) |
| os.makedirs("static", exist_ok=True) |
| os.environ["GRADIO_TEMP_DIR"] = os.path.abspath("temp") |
|
|
| |
| 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") |
| } |
|
|
| |
| for img_path_rel in EXAMPLE_IMAGES: |
| |
| 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: |
| |
| os.makedirs(os.path.dirname(img_path_abs), exist_ok=True) |
| placeholder_img = Image.new('RGB', (200, 200), color='lightgrey') |
| |
| 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}") |
|
|
| |
| viewer_html = """ |
| <div id="loading-ketcher" style="display:flex;justify-content:center;align-items:center; width: 100%; height: 100%;"> |
| <p style="padding:0.2rem 1rem 0 0;color:#c1c1c1;font-size:1rem">loading SMILES editor</p> |
| <svg version="1.1" id="L4" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" |
| viewBox="0 0 100 100" enable-background="new 0 0 0 0" xml:space="preserve" width="5rem"> |
| <circle fill="#FF7C00" stroke="none" cx="6" cy="50" r="6"> |
| <animate attributeName="opacity" dur="1s" values="0;1;0" repeatCount="indefinite" begin="0.1"/> |
| </circle> |
| <circle fill="#FF7C00" stroke="none" cx="26" cy="50" r="6"> |
| <animate attributeName="opacity" dur="1s" values="0;1;0" repeatCount="indefinite" begin="0.2"/> |
| </circle> |
| <circle fill="#FF7C00" stroke="none" cx="46" cy="50" r="6"> |
| <animate attributeName="opacity" dur="1s" values="0;1;0" repeatCount="indefinite" begin="0.3"/> |
| </circle> |
| </svg> |
| </div> |
| <div id="root" style="width: 100%; height: 550px; display: none;"></div> |
| """ |
|
|
| |
| sidebar_html = """ |
| <div id="sidebar-container" style="display: none;"> <div class="sidebar-header"> |
| <h3 id="sidebar-title">Molecule Data</h3> |
| <button id="close-sidebar" class="icon-button"> |
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg> |
| </button> |
| </div> |
| <div class="sidebar-actions"> |
| <button id="copy-btn-s" class="icon-button" title="Copy to clipboard"> |
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg> |
| </button> |
| <button id="download-btn" class="icon-button" title="Download file"> |
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg> |
| </button> |
| </div> |
| <div class="sidebar-content"> |
| <div id="content-area"></div> |
| </div> |
| </div> |
| <textarea id="copy-textarea" style="position: absolute; left: -9999px; top: 0;" aria-hidden="true"></textarea> |
| """ |
|
|
| |
| 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 = '<pre>' + content.replace(/</g, '<').replace(/>/g, '>') + '</pre>'; |
| } else { |
| // For SMILES, word-wrap but don't add pre tags |
| formattedContent = '<div style="word-break: break-all;">' + content.replace(/</g, '<').replace(/>/g, '>') + '</div>'; |
| } |
| |
| 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 = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="green" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>'; |
| setTimeout(() => { |
| copyBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2 2v1"></path></svg>'; |
| }, 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"); |
| } |
| """ |
|
|
| |
| 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 = "<p style='color:red;'>Failed to load SMILES editor.</p>"; |
| }; |
| document.head.appendChild(script); |
| }).catch(err => { |
| console.error("Failed to fetch Ketcher JS:", err); |
| loadingDiv.innerHTML = "<p style='color:red;'>Failed to fetch SMILES editor assets.</p>"; |
| }); |
| } |
| """ |
|
|
| |
| 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'); |
| } |
| """ |
|
|
| |
| 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(); |
| } |
| """ |
|
|
| |
| 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 ""; |
| } |
| } |
| """ |
|
|
| |
| 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 ""; |
| } |
| } |
| """ |
|
|
| |
| 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'); |
| } |
| } |
| """ |
|
|
| |
| 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) |
| current_molfile_path_state = gr.State(None) |
| session_id_state = gr.State(value=lambda: str(uuid.uuid4())) |
| |
| multiple_molecules_state = gr.State([]) |
|
|
| |
| molecules_json_trigger = gr.Textbox(visible=False, label="Molecules JSON for JS") |
| smiles_data = gr.Textbox(visible=False) |
| molfile_data = gr.Textbox(visible=False) |
| |
|
|
| with gr.Column(elem_id="app"): |
| gr.HTML(sidebar_html) |
| |
| with gr.Row(elem_id="header"): |
| gr.HTML("<h1>CLIP-OCSR: CLIP-empowered Optical Chemical Structure Recognition</h1><p style='font-size: 18px;'>Online tool for chemical structure extraction</p>") |
| |
| 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(""" |
| <div id="running-indicator"> |
| </div> |
| """) |
| 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"): |
| |
| 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="<div id='processed-smiles-box'></div>" |
| ) |
| copy_btn = gr.Button( |
| "📋", |
| elem_id="copy-btn", |
| ) |
| |
| with gr.Column(elem_id="examples-section"): |
| gr.HTML("<h3>Examples</h3>") |
| 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) |
| |
| |
| 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() |
| processed_smiles = response1.json().get('predicted_smiles') |
| |
|
|
| if processed_smiles: |
| |
| if "$" in processed_smiles: |
| try: |
| response4 = requests.post(f"{url}/rearrange", data={"smiles_v": processed_smiles}, headers=headers) |
| response4.raise_for_status() |
| smi_set = response4.json().get('smi_set') |
| |
| if isinstance(smi_set, list) and len(smi_set) > 1: |
| |
| for i, smiles in enumerate(smi_set): |
| response2 = requests.post(f"{url}/smi2mol", data={"smiles": smiles}, headers=headers) |
| response2.raise_for_status() |
| mol_block = response2.json().get('mol_block') |
| |
| if mol_block: |
| molecules_list.append({ |
| 'smiles': smiles, |
| 'molfile': mol_block, |
| 'index': i |
| }) |
| |
| |
| if molecules_list: |
| |
| predicted_smiles_for_state = molecules_list[0]['smiles'] |
| molecules_json = json.dumps(molecules_list) |
| |
| |
| tabs_buttons = [] |
| for i, mol in enumerate(molecules_list): |
| active_class = "active" if i == 0 else "" |
| tabs_buttons.append(f'<button class="mol-tab-btn {active_class}" onclick="window.switchToMolecule({i})">Mol{i+1}</button>') |
| tabs_html = f'<div style="margin-bottom: 10px;">{"".join(tabs_buttons)}</div>' |
| |
| 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: |
| |
| 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() |
| 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 = '<div style="margin-bottom: 10px;"><button class="mol-tab-btn active" onclick="window.switchToMolecule(0)">Mol1</button></div>' |
| 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}") |
| |
| predicted_smiles_for_state = processed_smiles |
| response2 = requests.post(f"{url}/smi2mol", data={"smiles": processed_smiles}, headers=headers) |
| response2.raise_for_status() |
| 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 = '<div style="margin-bottom: 10px;"><button class="mol-tab-btn active" onclick="window.switchToMolecule(0)">Mol1</button></div>' |
| else: |
| |
| predicted_smiles_for_state = processed_smiles |
| response2 = requests.post(f"{url}/smi2mol", data={"smiles": processed_smiles}, headers=headers) |
| response2.raise_for_status() |
| 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 = '<div style="margin-bottom: 10px;"><button class="mol-tab-btn active" onclick="window.switchToMolecule(0)">Mol1</button></div>' |
| 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) |
| |
| 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="<div id='processed-smiles-box'></div>") |
| |
| try: |
| |
| response3 = requests.post(f"{url}/sma2smi", data={"sma": ketcher_smiles}, headers=headers) |
| response3.raise_for_status() |
| processed_smiles = response3.json().get('smi') |
| |
| escaped_smiles = processed_smiles.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"').replace("'", ''') |
| return gr.update(value=f"<div id='processed-smiles-box'>{escaped_smiles}</div>") |
| 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="<div id='processed-smiles-box'>Error processing SMILES</div>") |
|
|
| |
| |
| def combined_recognition_workflow(img_data_from_state, current_session_id): |
| empty = gr.HTML(value="<div id='processed-smiles-box'></div>") |
| 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] |
| ) |
| |
| |
| start_btn.click( |
| fn=combined_recognition_workflow, |
| inputs=[uploaded_image_state, session_id_state], |
| outputs=[ |
| current_smiles_state, |
| multiple_molecules_state, |
| molecules_json_trigger, |
| molecule_tabs_area, |
| processed_smiles_output, |
| |
| ], |
| ) |
| |
|
|
| |
| 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 |
| ) |
| |
| |
| 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.") |