feat: Flask Web UI — 在线cDNA图像处理平台
- 上传图像 + 实时处理 + 6张结果可视化 - 实验室仪器风格深色主题 - 参数统计面板(T/pct/网格/斑点数) - 图片点击放大 + 单张/全部下载
This commit is contained in:
+211
@@ -0,0 +1,211 @@
|
|||||||
|
"""
|
||||||
|
cDNA微阵列图像处理 - Web UI (Flask)
|
||||||
|
=====================================
|
||||||
|
启动:python web/app.py
|
||||||
|
打开:http://localhost:5000
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os, sys, io, base64
|
||||||
|
from flask import Flask, render_template, request, jsonify
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use('Agg')
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
from skimage import color
|
||||||
|
from scipy import ndimage
|
||||||
|
|
||||||
|
# 项目根目录加到 sys.path
|
||||||
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, os.path.join(BASE_DIR, 'src'))
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 50MB
|
||||||
|
UPLOAD_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'uploads')
|
||||||
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei']
|
||||||
|
plt.rcParams['axes.unicode_minus'] = False
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# 图像处理函数(同简化版逻辑)
|
||||||
|
# ================================================================
|
||||||
|
def otsu_threshold_pixels(gray):
|
||||||
|
best_T, best_cost, total = 0, float('inf'), gray.size
|
||||||
|
for T in range(1, 255):
|
||||||
|
bg, fg = gray[gray <= T], gray[gray > T]
|
||||||
|
if len(bg) == 0 or len(fg) == 0:
|
||||||
|
continue
|
||||||
|
cost = len(bg)/total*np.var(bg) + len(fg)/total*np.var(fg)
|
||||||
|
if cost < best_cost:
|
||||||
|
best_cost, best_T = cost, T
|
||||||
|
return best_T
|
||||||
|
|
||||||
|
def draw_grid_lines(gray):
|
||||||
|
T = otsu_threshold_pixels(gray)
|
||||||
|
pct = T / 255.0
|
||||||
|
H, W = gray.shape
|
||||||
|
col_prof = np.sum(gray, axis=0).astype(float)
|
||||||
|
row_prof = np.sum(gray, axis=1).astype(float)
|
||||||
|
col_T = (np.max(col_prof)-np.min(col_prof))*pct
|
||||||
|
row_T = (np.max(row_prof)-np.min(row_prof))*pct
|
||||||
|
col_s, row_s = col_prof-col_T, row_prof-row_T
|
||||||
|
|
||||||
|
def find_gap_lines(prof):
|
||||||
|
is_pos = prof > 0
|
||||||
|
crossings = [i for i in range(1, len(is_pos)) if is_pos[i] != is_pos[i-1]]
|
||||||
|
if len(crossings) < 2:
|
||||||
|
return np.array([])
|
||||||
|
start = 1 if not is_pos[0] else 0
|
||||||
|
return np.array([int((crossings[k]+crossings[k+1])/2) for k in range(start, len(crossings)-1, 2)])
|
||||||
|
|
||||||
|
xl = find_gap_lines(col_s)
|
||||||
|
yl = find_gap_lines(row_s)
|
||||||
|
return xl, yl, T, pct, col_prof, row_prof, col_s, row_s, col_T, row_T
|
||||||
|
|
||||||
|
def keep_largest_object(binary):
|
||||||
|
L, n = ndimage.label(binary)
|
||||||
|
if n == 0: return np.zeros_like(binary)
|
||||||
|
return (L == (np.argmax([int(np.sum(L==i)) for i in range(1,n+1)])+1)).astype(np.uint8)
|
||||||
|
|
||||||
|
def remove_small_objects(binary):
|
||||||
|
L, n = ndimage.label(binary)
|
||||||
|
if n == 0: return binary
|
||||||
|
areas = [int(np.sum(L==i)) for i in range(1,n+1)]
|
||||||
|
minsz = max(1, int(np.median(areas)*0.25))
|
||||||
|
r = binary.copy()
|
||||||
|
for i in range(1, n+1):
|
||||||
|
if areas[i-1] < minsz: r[L==i] = 0
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def fig_to_base64(fig):
|
||||||
|
"""matplotlib figure → base64 PNG"""
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=120, bbox_inches='tight')
|
||||||
|
buf.seek(0)
|
||||||
|
b64 = base64.b64encode(buf.read()).decode()
|
||||||
|
plt.close(fig)
|
||||||
|
return f'data:image/png;base64,{b64}'
|
||||||
|
|
||||||
|
|
||||||
|
def process_image(img_array):
|
||||||
|
"""对上传的图像运行完整处理流程,返回 dict"""
|
||||||
|
# 转灰度
|
||||||
|
if img_array.ndim == 3 and img_array.shape[2] >= 3:
|
||||||
|
gray = (color.rgb2gray(img_array[:,:,:3])*255).astype(np.uint8)
|
||||||
|
else:
|
||||||
|
gray = img_array.astype(np.uint8)
|
||||||
|
|
||||||
|
# 网格划线
|
||||||
|
xl, yl, T, pct, cp, rp, cs, rs, cT, rT = draw_grid_lines(gray)
|
||||||
|
|
||||||
|
# 逐格分割
|
||||||
|
bw = np.zeros_like(gray)
|
||||||
|
for i in range(len(yl)-1):
|
||||||
|
for j in range(len(xl)-1):
|
||||||
|
r1, r2 = yl[i], yl[i+1]
|
||||||
|
c1, c2 = xl[j], xl[j+1]
|
||||||
|
blk = gray[r1:r2, c1:c2]
|
||||||
|
if blk.size == 0: continue
|
||||||
|
bt = otsu_threshold_pixels(blk)
|
||||||
|
bb = keep_largest_object((blk > bt).astype(np.uint8))
|
||||||
|
bw[r1:r2, c1:c2] = bb
|
||||||
|
bw_clean = remove_small_objects(bw)
|
||||||
|
|
||||||
|
# 统计
|
||||||
|
L, n = ndimage.label(bw_clean)
|
||||||
|
spots = [int(np.sum(L==i)) for i in range(1,n+1)]
|
||||||
|
valid = [s for s in spots if s >= 10]
|
||||||
|
|
||||||
|
# ---- 生成6张图 ----
|
||||||
|
images = {}
|
||||||
|
|
||||||
|
# 1: grid overlay
|
||||||
|
fig, ax = plt.subplots(figsize=(6,6))
|
||||||
|
ax.imshow(gray, cmap='gray')
|
||||||
|
for x in xl: ax.axvline(x=x, color='lime', linewidth=0.5)
|
||||||
|
for y in yl: ax.axhline(y=y, color='lime', linewidth=0.5)
|
||||||
|
ax.set_title(f'Grid ({len(xl)}x{len(yl)})', fontsize=12); ax.axis('off')
|
||||||
|
images['grid_overlay'] = fig_to_base64(fig)
|
||||||
|
|
||||||
|
# 2: col projection
|
||||||
|
fig, ax = plt.subplots(figsize=(10,4))
|
||||||
|
xs = np.arange(len(cp)); ax.plot(xs,cp,'b-',lw=0.6); ax.axhline(y=cT,color='orange',ls='--',lw=1)
|
||||||
|
ax.plot(xs,cs,'g-',lw=0.6,alpha=0.5)
|
||||||
|
ax.fill_between(xs,0,cs,where=(cs>0),color='green',alpha=0.1)
|
||||||
|
ax.fill_between(xs,0,cs,where=(cs<0),color='red',alpha=0.1)
|
||||||
|
for x in xl: ax.axvline(x=x,color='red',lw=0.5,alpha=0.5)
|
||||||
|
ax.set_title('Column Projection', fontsize=12); ax.set_xlabel('column')
|
||||||
|
images['col_projection'] = fig_to_base64(fig)
|
||||||
|
|
||||||
|
# 3: row projection
|
||||||
|
fig, ax = plt.subplots(figsize=(10,4))
|
||||||
|
ys = np.arange(len(rp)); ax.plot(rp,ys,'b-',lw=0.6); ax.axvline(x=rT,color='orange',ls='--',lw=1)
|
||||||
|
ax.plot(rs,ys,'g-',lw=0.6,alpha=0.5)
|
||||||
|
for y in yl: ax.axhline(y=y,color='red',lw=0.5,alpha=0.5)
|
||||||
|
ax.set_title('Row Projection', fontsize=12); ax.set_ylabel('row')
|
||||||
|
images['row_projection'] = fig_to_base64(fig)
|
||||||
|
|
||||||
|
# 4: histogram
|
||||||
|
fig, ax = plt.subplots(figsize=(7,4))
|
||||||
|
ax.hist(gray.ravel(),bins=50,color='#2d8a4e',edgecolor='white',linewidth=0.3)
|
||||||
|
ax.axvline(x=T,color='#ff4444',ls='--',lw=2,label=f'Otsu T={T} ({pct*100:.1f}%)')
|
||||||
|
ax.set_title('Histogram + Otsu', fontsize=12); ax.legend()
|
||||||
|
images['histogram'] = fig_to_base64(fig)
|
||||||
|
|
||||||
|
# 5: segmentation raw
|
||||||
|
fig, ax = plt.subplots(figsize=(6,6))
|
||||||
|
ax.imshow(bw, cmap='gray'); ax.set_title('Segmentation (raw)', fontsize=12); ax.axis('off')
|
||||||
|
images['segmentation_raw'] = fig_to_base64(fig)
|
||||||
|
|
||||||
|
# 6: post processed
|
||||||
|
fig, ax = plt.subplots(figsize=(6,6))
|
||||||
|
ax.imshow(bw_clean, cmap='gray')
|
||||||
|
ax.set_title(f'Post-processed ({len(valid)} spots)', fontsize=12); ax.axis('off')
|
||||||
|
images['post_processed'] = fig_to_base64(fig)
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'spots': len(valid),
|
||||||
|
'T_otsu': int(T),
|
||||||
|
'pct': round(pct*100, 1),
|
||||||
|
'lines_x': int(len(xl)),
|
||||||
|
'lines_y': int(len(yl)),
|
||||||
|
'width': int(gray.shape[1]),
|
||||||
|
'height': int(gray.shape[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {'images': images, 'stats': stats}
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# Flask 路由
|
||||||
|
# ================================================================
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
@app.route('/process', methods=['POST'])
|
||||||
|
def process():
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return jsonify({'error': '未找到文件'}), 400
|
||||||
|
file = request.files['file']
|
||||||
|
if file.filename == '':
|
||||||
|
return jsonify({'error': '文件名为空'}), 400
|
||||||
|
|
||||||
|
# 读取图像
|
||||||
|
img_bytes = file.read()
|
||||||
|
img = Image.open(io.BytesIO(img_bytes))
|
||||||
|
img_array = np.array(img)
|
||||||
|
|
||||||
|
# 处理
|
||||||
|
try:
|
||||||
|
result = process_image(img_array)
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': f'处理失败: {str(e)}'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(debug=True, port=5000)
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
/* ============================================================
|
||||||
|
cDNA Lab Console — Bio-laboratory Instrument Display Theme
|
||||||
|
Dark background + fluorescent green accents + monospace data
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=DM+Sans:wght@400;500;700&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #080e08;
|
||||||
|
--bg-panel: #0d160d;
|
||||||
|
--border: #1a2e1a;
|
||||||
|
--green: #00e676;
|
||||||
|
--green-dim: #1b5e20;
|
||||||
|
--green-glow: rgba(0, 230, 118, 0.25);
|
||||||
|
--text: #b8c7b8;
|
||||||
|
--text-dim: #5a6e5a;
|
||||||
|
--text-bright: #e0f0e0;
|
||||||
|
--red: #ff3d3d;
|
||||||
|
--yellow: #ffc107;
|
||||||
|
--font-mono: 'Share Tech Mono', 'Courier New', monospace;
|
||||||
|
--font-ui: 'DM Sans', 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Grain overlay ---- */
|
||||||
|
.noise-overlay {
|
||||||
|
position: fixed; inset: 0; pointer-events: none; z-index: 999;
|
||||||
|
opacity: 0.04;
|
||||||
|
background: repeating-conic-gradient(#000 0%, transparent .0002%, #000 .0004%, transparent .0008%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Header ---- */
|
||||||
|
.header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 16px 32px; border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
}
|
||||||
|
.header-left { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.logo-bracket { color: var(--green); font-size: 1.5rem; font-family: var(--font-mono); opacity: 0.6; }
|
||||||
|
.logo-text {
|
||||||
|
font-family: var(--font-mono); font-size: 1.4rem; font-weight: 400;
|
||||||
|
color: var(--text-bright); letter-spacing: 0.15em;
|
||||||
|
}
|
||||||
|
.logo-accent { color: var(--green); font-weight: 700; }
|
||||||
|
.header-right { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.status-dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%; background: var(--green);
|
||||||
|
box-shadow: 0 0 8px var(--green-glow); animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||||
|
.status-label { font-family: var(--font-mono); font-size: 0.8rem; color: var(--text-dim); letter-spacing: 0.2em; }
|
||||||
|
|
||||||
|
/* ---- Container ---- */
|
||||||
|
.container { max-width: 1100px; margin: 0 auto; padding: 32px 24px; }
|
||||||
|
|
||||||
|
/* ---- Upload Zone ---- */
|
||||||
|
.upload-section { margin-bottom: 28px; }
|
||||||
|
.upload-zone-wrapper { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.upload-zone {
|
||||||
|
border: 2px dashed var(--border); border-radius: 8px;
|
||||||
|
padding: 48px 24px; text-align: center; cursor: pointer;
|
||||||
|
transition: all 0.3s ease; background: var(--bg-panel);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.upload-zone:hover, .upload-zone.dragover {
|
||||||
|
border-color: var(--green); background: rgba(0,230,118,0.04);
|
||||||
|
box-shadow: inset 0 0 40px rgba(0,230,118,0.03);
|
||||||
|
}
|
||||||
|
.upload-zone.dragover { border-style: solid; }
|
||||||
|
.upload-svg { color: var(--green-dim); margin-bottom: 12px; transition: color 0.3s; }
|
||||||
|
.upload-zone:hover .upload-svg { color: var(--green); }
|
||||||
|
.upload-text {
|
||||||
|
font-family: var(--font-mono); font-size: 1.1rem; color: var(--text-bright);
|
||||||
|
letter-spacing: 0.1em; margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.upload-cursor {
|
||||||
|
display: inline-block; color: var(--green); animation: blink 1s step-end infinite;
|
||||||
|
}
|
||||||
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
|
.upload-sub { font-size: 0.85rem; color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* ---- Preview + Button ---- */
|
||||||
|
.upload-preview { display: flex; flex-direction: column; align-items: center; gap: 16px; }
|
||||||
|
.upload-preview img {
|
||||||
|
max-width: 100%; max-height: 300px; border-radius: 6px;
|
||||||
|
border: 1px solid var(--border); object-fit: contain; background: #000;
|
||||||
|
}
|
||||||
|
.btn-process {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 12px 32px; border: none; border-radius: 4px;
|
||||||
|
background: var(--green-dim); color: var(--green);
|
||||||
|
font-family: var(--font-mono); font-size: 0.95rem; letter-spacing: 0.15em;
|
||||||
|
cursor: pointer; transition: all 0.25s;
|
||||||
|
}
|
||||||
|
.btn-process:hover { background: var(--green); color: #000; }
|
||||||
|
|
||||||
|
/* ---- Status Bar ---- */
|
||||||
|
.status-bar {
|
||||||
|
display: flex; align-items: center; gap: 14px;
|
||||||
|
padding: 10px 16px; background: var(--bg-panel); border: 1px solid var(--border);
|
||||||
|
border-radius: 4px; margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.status-track {
|
||||||
|
flex: 1; height: 4px; background: var(--border); border-radius: 2px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.status-fill {
|
||||||
|
height: 100%; width: 0%; background: var(--green);
|
||||||
|
transition: width 0.1s linear;
|
||||||
|
box-shadow: 0 0 6px var(--green-glow);
|
||||||
|
}
|
||||||
|
.status-text { font-family: var(--font-mono); font-size: 0.8rem; color: var(--green); letter-spacing: 0.15em; }
|
||||||
|
|
||||||
|
/* ---- Error ---- */
|
||||||
|
.error-msg {
|
||||||
|
background: rgba(255,61,61,0.1); border: 1px solid var(--red); color: var(--red);
|
||||||
|
padding: 12px 18px; border-radius: 4px; margin-bottom: 20px;
|
||||||
|
font-family: var(--font-mono); font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Stats Panel ---- */
|
||||||
|
.stats-panel {
|
||||||
|
display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
flex: 1; min-width: 140px; padding: 16px 20px;
|
||||||
|
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
text-align: center; transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
.stat-card:hover { border-color: var(--green-dim); }
|
||||||
|
.stat-card.spot { border-color: var(--green-dim); background: rgba(0,230,118,0.04); }
|
||||||
|
.stat-label {
|
||||||
|
display: block; font-family: var(--font-mono); font-size: 0.7rem;
|
||||||
|
color: var(--text-dim); letter-spacing: 0.2em; margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
display: block; font-family: var(--font-mono); font-size: 1.5rem;
|
||||||
|
color: var(--green); font-weight: 400;
|
||||||
|
}
|
||||||
|
.stat-card.spot .stat-value { font-size: 2rem; }
|
||||||
|
|
||||||
|
/* ---- Gallery ---- */
|
||||||
|
.gallery { margin-bottom: 40px; }
|
||||||
|
.gallery-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.gallery-title {
|
||||||
|
font-family: var(--font-mono); font-size: 1.1rem; font-weight: 400;
|
||||||
|
color: var(--text-bright); letter-spacing: 0.2em;
|
||||||
|
}
|
||||||
|
.btn-dl-all {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
padding: 8px 20px; border: 1px solid var(--green-dim); border-radius: 4px;
|
||||||
|
background: transparent; color: var(--green);
|
||||||
|
font-family: var(--font-mono); font-size: 0.8rem; letter-spacing: 0.1em;
|
||||||
|
cursor: pointer; transition: all 0.25s;
|
||||||
|
}
|
||||||
|
.btn-dl-all:hover { background: var(--green-dim); }
|
||||||
|
.btn-dl-all span { font-size: 1rem; }
|
||||||
|
|
||||||
|
.gallery-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px;
|
||||||
|
}
|
||||||
|
@media (max-width: 800px) { .gallery-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
@media (max-width: 500px) { .gallery-grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
.gallery-card { border-radius: 4px; overflow: hidden; background: var(--bg-panel); border: 1px solid var(--border); }
|
||||||
|
.gallery-card-inner {
|
||||||
|
aspect-ratio: 1; overflow: hidden; display: flex; align-items: center; justify-content: center;
|
||||||
|
background: #000; cursor: pointer;
|
||||||
|
}
|
||||||
|
.gallery-card-inner img {
|
||||||
|
width: 100%; height: 100%; object-fit: contain;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
.gallery-card-inner:hover img { transform: scale(1.05); }
|
||||||
|
.gallery-card-label {
|
||||||
|
padding: 8px 12px; font-family: var(--font-mono); font-size: 0.65rem;
|
||||||
|
color: var(--text-dim); letter-spacing: 0.1em; text-align: center;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Lightbox ---- */
|
||||||
|
.lightbox {
|
||||||
|
display: none; position: fixed; inset: 0; z-index: 1000;
|
||||||
|
background: rgba(0,0,0,0.92); flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.lightbox img { max-width: 90vw; max-height: 85vh; object-fit: contain; border: 1px solid var(--border); background: #000; }
|
||||||
|
.lightbox-close {
|
||||||
|
position: absolute; top: 20px; right: 30px;
|
||||||
|
font-size: 2rem; color: var(--text-dim); cursor: pointer; transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.lightbox-close:hover { color: var(--text-bright); }
|
||||||
|
.lightbox-dl {
|
||||||
|
margin-top: 16px; padding: 10px 24px; border: 1px solid var(--green); border-radius: 4px;
|
||||||
|
color: var(--green); text-decoration: none; font-family: var(--font-mono); font-size: 0.9rem;
|
||||||
|
transition: all 0.25s;
|
||||||
|
}
|
||||||
|
.lightbox-dl:hover { background: var(--green); color: #000; }
|
||||||
|
|
||||||
|
/* ---- Scrollbar ---- */
|
||||||
|
::-webkit-scrollbar { width: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: var(--bg); }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--green-dim); }
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>cDNA Microarray Processing - Lab Console</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="noise-overlay"></div>
|
||||||
|
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<span class="logo-bracket">[</span>
|
||||||
|
<h1 class="logo-text">cDNA<span class="logo-accent">//</span>PROCESS</h1>
|
||||||
|
<span class="logo-bracket">]</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
<span class="status-label">SYSTEM READY</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<section class="upload-section" id="uploadSection">
|
||||||
|
<div class="upload-zone-wrapper">
|
||||||
|
<div class="upload-zone" id="dropZone">
|
||||||
|
<svg class="upload-svg" width="52" height="52" viewBox="0 0 52 52">
|
||||||
|
<circle cx="26" cy="20" r="8" stroke="currentColor" stroke-width="1.5" fill="none"/>
|
||||||
|
<path d="M13 42c-5 0-8-1.5-8-6s3.5-9 8-9c0-6 6-10 13-10s13 4 13 10c4.5 0 8 4.5 8 9s-3.5 6-8 6H13z" stroke="currentColor" stroke-width="1.5" fill="none"/>
|
||||||
|
<circle cx="22" cy="16" r="2.2" fill="currentColor"/>
|
||||||
|
<circle cx="26" cy="11" r="2.8" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
<p class="upload-text">Drop image here<span class="upload-cursor">_</span></p>
|
||||||
|
<p class="upload-sub">click to browse · TIFF / PNG / JPEG</p>
|
||||||
|
<input type="file" id="fileInput" accept="image/*" hidden>
|
||||||
|
</div>
|
||||||
|
<div class="upload-preview" id="uploadPreview" style="display:none">
|
||||||
|
<img id="previewImg" alt="Preview">
|
||||||
|
<button class="btn-process" id="btnProcess">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16"><polygon points="3,1 16,8 3,15" fill="currentColor"/></svg>
|
||||||
|
START ANALYSIS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="status-bar" id="statusBar" style="display:none">
|
||||||
|
<div class="status-track">
|
||||||
|
<div class="status-fill" id="statusFill"></div>
|
||||||
|
</div>
|
||||||
|
<span class="status-text" id="statusText">PROCESSING...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="error-msg" id="errorMsg" style="display:none"></div>
|
||||||
|
|
||||||
|
<section class="stats-panel" id="statsPanel" style="display:none">
|
||||||
|
<div class="stat-card"><span class="stat-label">THRESHOLD</span><span class="stat-value" id="statT">--</span></div>
|
||||||
|
<div class="stat-card"><span class="stat-label">ADAPTIVE %</span><span class="stat-value" id="statPct">--</span></div>
|
||||||
|
<div class="stat-card"><span class="stat-label">GRID</span><span class="stat-value" id="statGrid">--</span></div>
|
||||||
|
<div class="stat-card spot"><span class="stat-label">SPOTS FOUND</span><span class="stat-value" id="statSpots">--</span></div>
|
||||||
|
<div class="stat-card"><span class="stat-label">DIMENSIONS</span><span class="stat-value" id="statSize">--</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="gallery" id="gallery" style="display:none">
|
||||||
|
<div class="gallery-header">
|
||||||
|
<h2 class="gallery-title">RESULTS</h2>
|
||||||
|
<button class="btn-dl-all" id="btnDownloadAll"><span>↓</span> DOWNLOAD ALL</button>
|
||||||
|
</div>
|
||||||
|
<div class="gallery-grid" id="galleryGrid"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="lightbox" id="lightbox">
|
||||||
|
<span class="lightbox-close" id="lightboxClose">×</span>
|
||||||
|
<img id="lightboxImg" src="" alt="Full size">
|
||||||
|
<a id="lightboxDownload" class="lightbox-dl" download>↓</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var dz=document.getElementById('dropZone');
|
||||||
|
var fi=document.getElementById('fileInput');
|
||||||
|
var up=document.getElementById('uploadPreview');
|
||||||
|
var pi=document.getElementById('previewImg');
|
||||||
|
var bp=document.getElementById('btnProcess');
|
||||||
|
var sb=document.getElementById('statusBar');
|
||||||
|
var sf=document.getElementById('statusFill');
|
||||||
|
var st=document.getElementById('statusText');
|
||||||
|
var er=document.getElementById('errorMsg');
|
||||||
|
var sp=document.getElementById('statsPanel');
|
||||||
|
var ga=document.getElementById('gallery');
|
||||||
|
var gg=document.getElementById('galleryGrid');
|
||||||
|
var lb=document.getElementById('lightbox');
|
||||||
|
var li=document.getElementById('lightboxImg');
|
||||||
|
var lc=document.getElementById('lightboxClose');
|
||||||
|
var ld=document.getElementById('lightboxDownload');
|
||||||
|
var file=null;
|
||||||
|
|
||||||
|
dz.addEventListener('dragover',function(e){e.preventDefault();dz.classList.add('dragover')});
|
||||||
|
dz.addEventListener('dragleave',function(){dz.classList.remove('dragover')});
|
||||||
|
dz.addEventListener('drop',function(e){e.preventDefault();dz.classList.remove('dragover');if(e.dataTransfer.files.length)h(e.dataTransfer.files[0])});
|
||||||
|
dz.addEventListener('click',function(){fi.click()});
|
||||||
|
fi.addEventListener('change',function(e){if(e.target.files.length)h(e.target.files[0])});
|
||||||
|
|
||||||
|
function h(f){
|
||||||
|
file=f;
|
||||||
|
var r=new FileReader();
|
||||||
|
r.onload=function(e){
|
||||||
|
pi.src=e.target.result;
|
||||||
|
up.style.display='flex';
|
||||||
|
er.style.display='none';
|
||||||
|
sp.style.display='none';
|
||||||
|
ga.style.display='none';
|
||||||
|
};
|
||||||
|
r.readAsDataURL(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
bp.addEventListener('click',function(){
|
||||||
|
if(!file) return;
|
||||||
|
var fd=new FormData(); fd.append('file',file);
|
||||||
|
sb.style.display='block'; sf.style.width='0%'; st.textContent='UPLOADING...'; er.style.display='none';
|
||||||
|
var w=0; var iv=setInterval(function(){w=Math.min(w+2,85);sf.style.width=w+'%'},50);
|
||||||
|
fetch('/process',{method:'POST',body:fd}).then(function(r){
|
||||||
|
clearInterval(iv); sf.style.width='100%'; st.textContent='COMPLETE';
|
||||||
|
if(!r.ok) throw new Error('Server error');
|
||||||
|
return r.json();
|
||||||
|
}).then(function(d){
|
||||||
|
if(d.error) throw new Error(d.error);
|
||||||
|
render(d);
|
||||||
|
setTimeout(function(){sb.style.display='none'},1500);
|
||||||
|
}).catch(function(e){
|
||||||
|
clearInterval(iv); sb.style.display='none';
|
||||||
|
er.textContent='ERROR: '+e.message; er.style.display='block';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function render(d){
|
||||||
|
var s=d.stats;
|
||||||
|
document.getElementById('statT').textContent=s.T_otsu;
|
||||||
|
document.getElementById('statPct').textContent=s.pct+'%';
|
||||||
|
document.getElementById('statGrid').textContent=s.lines_x+' x '+s.lines_y;
|
||||||
|
document.getElementById('statSpots').textContent=s.spots;
|
||||||
|
document.getElementById('statSize').textContent=s.width+' x '+s.height;
|
||||||
|
sp.style.display='flex';
|
||||||
|
var names=['grid_overlay','col_projection','row_projection','histogram','segmentation_raw','post_processed'];
|
||||||
|
var labels=['GRID OVERLAY','COLUMN PROJECTION','ROW PROJECTION','HISTOGRAM + OTSU','SEGMENTATION','POST-PROCESSED'];
|
||||||
|
gg.innerHTML='';
|
||||||
|
names.forEach(function(n,i){
|
||||||
|
var c=document.createElement('div'); c.className='gallery-card';
|
||||||
|
c.innerHTML='<div class="gallery-card-inner"><img src="'+d.images[n]+'" onclick="zoom(\''+n+'.png\',\''+d.images[n]+'\')"></div><div class="gallery-card-label">'+labels[i]+'</div>';
|
||||||
|
gg.appendChild(c);
|
||||||
|
});
|
||||||
|
ga.style.display='block';
|
||||||
|
document.getElementById('btnDownloadAll').onclick=function(){
|
||||||
|
names.forEach(function(n,i){
|
||||||
|
setTimeout(function(){
|
||||||
|
var a=document.createElement('a'); a.href=d.images[n]; a.download=n+'.png';
|
||||||
|
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||||
|
},i*200);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.zoom=function(name,src){
|
||||||
|
lb.style.display='flex'; li.src=src; ld.href=src; ld.download=name;
|
||||||
|
};
|
||||||
|
|
||||||
|
lc.addEventListener('click',function(){lb.style.display='none'});
|
||||||
|
lb.addEventListener('click',function(e){if(e.target===lb) lb.style.display='none'});
|
||||||
|
document.addEventListener('keydown',function(e){if(e.key==='Escape') lb.style.display='none'});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user