Drop image here_
+click to browse · TIFF / PNG / JPEG
+ +diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..282ec2b --- /dev/null +++ b/web/app.py @@ -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) diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..73918d6 --- /dev/null +++ b/web/static/style.css @@ -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); } diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..3dd5ef0 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,176 @@ + + +
+ + +Drop image here_
+click to browse · TIFF / PNG / JPEG
+ +