Compare commits

...

30 Commits

Author SHA1 Message Date
Serendipity e21595e72c docs: 更新README,新增Web应用和打包说明
- 新增Flask Web应用介绍和三种运行方式
- 新增PyInstaller打包流程
- 更新项目结构,包含web/、build_exe.py等
- 精简两版实现描述,突出各自特点

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 21:41:03 +08:00
Serendipity dd145d7651 docs: 依赖库清单 2026-05-08 16:30:32 +08:00
Serendipity 8d81c20032 chore: .gitignore增加PyInstaller产物,移除已提交的spec 2026-05-08 16:28:40 +08:00
Serendipity 187068f6cc build: 优化PyInstaller打包,排除无关包,exe缩小到68MB 2026-05-08 16:23:44 +08:00
Serendipity a5f4275e19 build: PyInstaller打包配置+启动器 2026-05-08 16:15:45 +08:00
Serendipity c7ba14a81a Revert: pct改回T/255,T/2550会破坏暗图处理 2026-05-08 16:08:47 +08:00
Serendipity 4dab42e932 fix: pct计算改为T/2550 2026-05-08 15:54:12 +08:00
Serendipity 06159eba19 style: 深蓝+白色学术风格重设计
替代荧光绿实验室主题,更干净专业
2026-05-08 15:49:39 +08:00
Serendipity 8657937214 fix: 用os.startfile替代webbrowser打开浏览器 2026-05-08 15:42:36 +08:00
Serendipity d180ec5e56 feat: 启动Flask时自动打开Edge浏览器 2026-05-08 15:38:32 +08:00
Serendipity 1041a66270 refactor: remove_small_objects用Otsu替代中位数25%
对连通域面积分布做Otsu自动找分界,不再拍脑袋定百分比
2026-05-08 15:31:47 +08:00
Serendipity b07e7a1182 feat: Flask Web UI — 在线cDNA图像处理平台
- 上传图像 + 实时处理 + 6张结果可视化
- 实验室仪器风格深色主题
- 参数统计面板(T/pct/网格/斑点数)
- 图片点击放大 + 单张/全部下载
2026-05-08 11:26:02 +08:00
Serendipity 862d02dce6 fix: 移动flowchart.drawio到项目根目录 2026-05-08 10:47:16 +08:00
Serendipity 34889647f8 docs: 更新流程图,补充分割/后处理/统计/可视化步骤 2026-05-08 10:33:04 +08:00
Serendipity 99c9d0263a feat: 简化版可视化拆分为6张独立图片
01_grid_overlay.png - 网格线叠加
02_col_projection.png - 列投影曲线
03_row_projection.png - 行投影曲线
04_histogram.png - 直方图+Otsu阈值
05_segmentation_raw.png - 逐格分割(后处理前)
06_post_processed.png - 最终二值图
2026-05-08 10:08:00 +08:00
Serendipity 70790c1d3e refactor: 用户调整注释格式 2026-05-08 09:58:57 +08:00
Serendipity f95e3de5bd refactor: 恢复简化版所有详细行内注释
每函数、每关键行都有中文注释说明原理
2026-05-08 09:55:38 +08:00
Serendipity dae90a8de0 feat: 简化版增加逐格分割+后处理+斑点统计
现在简化版也具备完整处理链:
网格划线 → 逐格Otsu → keep_largest_object → remove_small_objects → 统计
输出三栏图:网格/分割/后处理结果
2026-05-08 09:40:37 +08:00
Serendipity 085c27c050 refactor: 后处理阈值完全自动化,零人工参数
- keep_largest_object: 每格仅留最大块,不设最低门槛
- remove_small_objects: 统计全局面积中位数,<25%自动判定为噪声
2026-05-08 08:57:48 +08:00
Serendipity efc6704b14 refactor: 后处理min_size改为格子面积百分比,自适应不同分辨率
keep_largest_object: 格子面积的1%
remove_small_objects: 格子面积的2%
2026-05-08 08:54:47 +08:00
Serendipity 00836cd302 refactor: 重写注释,统一风格
- 顶部docstring改为算法流程总览+各步骤详解
- 两个函数各配职责明确的注释
- 主流程三个步骤注释简洁
2026-05-08 08:44:34 +08:00
Serendipity e726e62c44 refactor: 简化版用Otsu自适应百分比替代写死的10%
百分比 = T/255,自动根据图像数据推导,无需人为设定
2026-05-08 08:40:45 +08:00
Serendipity 52a6e1b244 docs: CLAUDE.md更新仓库地址(SSH优先,HTTPS备选) 2026-05-08 08:27:56 +08:00
Serendipity 8e30fd585b feat: 简化版增加Otsu阈值分割
输出改为左右对比图(网格划线 vs Otsu分割)
2026-05-08 08:23:16 +08:00
Serendipity bad3635f0a feat: 原版增加斑点数量统计
输出检测到的有效斑点数、面积统计(最小/最大/均值/中位数)
2026-05-07 22:09:13 +08:00
Serendipity 09d8b9d8fe docs: 添加两版差异说明文档 2026-05-07 21:49:01 +08:00
Serendipity d273d45a5b 删除 2026-05-07 19:19:12 +08:00
Serendipity ad14755405 测试 2026-05-07 19:16:26 +08:00
Serendipity 918bdbf939 docs: 更新教程文档,去除不必要的路径信息 2026-05-07 08:28:43 +08:00
Serendipity 5a23b16a59 docs: 更新算法步骤说明,去除不必要的路径信息 2026-05-07 08:17:10 +08:00
32 changed files with 1384 additions and 324 deletions
+8
View File
@@ -15,3 +15,11 @@ Thumbs.db
# Obsidian # Obsidian
.obsidian/ .obsidian/
# Flask
.playwright-mcp/
# PyInstaller 打包产物
dist/
build/
*.spec
+3 -1
View File
@@ -57,4 +57,6 @@ Python`D:\ProgramData\anaconda3\envs\my_env`
## 仓库 ## 仓库
Gitea: `ssh://git@192.168.5.8:2222/Serendipity/cDNA-image-processing.git` - **首选**(局域网 SSH: `ssh://git@192.168.5.8:2222/Serendipity/cDNA-image-processing.git`
- **备选**(外网 HTTPS: `https://lhy-git.liuhangyv.top/Serendipity/cDNA-image-processing.git`
- SSH 连不上时自动切换到 HTTPS
+108 -62
View File
@@ -4,22 +4,72 @@
--- ---
## 项目概述
河南理工大学计算机学院图像处理课程作业。Python 实现 cDNA 微阵列图像的**网格划分**与**阈值分割**,配套 Web 可视化界面和 Windows 可执行文件(exe)。
---
## 项目结构 ## 项目结构
``` ```
src/ cDNA微阵列图像处理作业/
├── cDNA_segmentation.py # 原版:网格划分 + 三种阈值分割 + TV去噪 ├── src/
── cDNA_gridding_simple.py # 简化版:网格划分,用于课堂讲解 │ ├── cDNA_segmentation.py # 版:网格划分 + 三种阈值分割 + TV去噪
│ └── cDNA_gridding_simple.py # 简化版:仅网格划分,用于课堂讲解
results/ # 原版输出(6张PNG)
results_simple/ # 简化版输出(网格叠加图 + 流程图) ├── web/ # Flask Web 应用
docs/ │ ├── app.py # Flask 主程序
└── gridding_simple_tutorial.md # 简化版逐行代码讲解教程 │ ├── launcher.py # PyInstaller 打包入口
│ ├── templates/index.html # 前端页面
│ └── static/style.css # 样式文件
├── build_exe.py # PyInstaller 打包脚本
├── cDNA_Analyzer.spec # PyInstaller spec 配置
├── flowchart.drawio # 算法流程图(用 Draw.io 打开)
├── results/ # 原版输出图像(6张)
├── results_simple/ # 简化版输出图像(6张)
├── docs/ # 技术文档
│ ├── cDNA 微阵列网格划分.md # 算法详解
│ ├── 两版差异说明.md # 简化版 vs 原版对比
│ └── 依赖库清单.md # Python 依赖
├── cDNA图像处理实例/ # 课程示例数据
└── 参考资料/ # MATLAB 参考代码 + 论文 PDF
``` ```
--- ---
## 两个Python实现 ## 快速开始
### 方式一:Windows 可执行文件(推荐)
下载 `dist/cDNA_Analyzer.exe` 并双击运行,浏览器自动打开 `http://localhost:5000`,上传图像即可处理。
### 方式二:Web 开发模式
```bash
cd web
python app.py
# 浏览器自动打开 http://localhost:5000
```
### 方式三:命令行脚本
```bash
# 简化版(课堂主讲,约150行)
python src/cDNA_gridding_simple.py
# 输出:results_simple/ 下 6 张图
# 原版(完整实现,430行)
python src/cDNA_segmentation.py
# 输出:results/ 下 6 张图
```
---
## 两种 Python 实现
### 简化版 `cDNA_gridding_simple.py`(课堂主讲) ### 简化版 `cDNA_gridding_simple.py`(课堂主讲)
@@ -27,16 +77,10 @@ docs/
``` ```
1. 灰度图 → 每列/行灰度值求和 → 投影曲线 1. 灰度图 → 每列/行灰度值求和 → 投影曲线
2. 阈值 X = (max - min) × 10%,曲线减去 X 2. Otsu 求自适应百分比 = T/255
3. 正 = 斑点,负 = 空隙 → 找过零点 → 配对取中点 → 划线 3. 阈值 X = (max - min) × 百分比,曲线减去 X
``` 4. 正 = 斑点,负 = 空隙 → 找过零点 → 配对取中点 → 划线
5. 逐格 Otsu 分割 + keep_largest_object + remove_small_objects
**运行**
```bash
cd src
python cDNA_gridding_simple.py
# 输出:results_simple/gridding_simple.png
``` ```
**特点** **特点**
@@ -44,67 +88,69 @@ python cDNA_gridding_simple.py
- 约 150 行代码,带详细中文注释 - 约 150 行代码,带详细中文注释
- 核心逻辑仅 30 行 - 核心逻辑仅 30 行
- 与原版网格线位置误差为 **0 像素** - 与原版网格线位置误差为 **0 像素**
- `find_gap_lines` 函数:减阈值 → 判断正负 → 找过零点 → 配对空隙中点 - 完全自动化,无需人工设定参数
### 原版 `cDNA_segmentation.py`(完整实现) ### 原版 `cDNA_segmentation.py`(完整实现)
**包含模块** | 模块 | 算法 | 参照 |
| 模块 | 算法 | 依赖 |
|------|------|------| |------|------|------|
| 网格划分 | 投影 → 自相关 → 白顶帽 → Otsu → 质心 | numpy, scipy, skimage | | 网格划分 | 投影 → 自相关 → 白顶帽 → Otsu → 质心 | MATLAB `GriddingAndCV.m` |
| 阈值分割 | 人工阈值、Otsu 自动阈值、迭代阈值 | 同上 | | 阈值分割 | 人工阈值、Otsu 自动阈值、迭代阈值 | |
| 去噪 | TV 全变分去噪(Chambolle 投影) | 同上 | | 去噪 | TV 全变分去噪(Chambolle 投影) | MATLAB `tvdenoise.m` |
| 后处理 | 去小连通域、保留最大连通域 | 同上 | | 后处理 | 去小连通域、保留最大连通域 | MATLAB `choice.m` |
**运行**
```bash
cd src
python cDNA_segmentation.py
# 输出:results/ 下 6 张图
```
**输出文件**
| 文件 | 内容 |
|------|------|
| `result_gridding.png` | 网格划分:原图+投影曲线+直方图 |
| `result_gridding_overlay.png` | 网格线叠加到原图 |
| `result_threshold_compare.png` | 三种阈值方法(人工/Otsu/迭代)对比 |
| `result_iterative_convergence.png` | 迭代阈值收敛曲线 |
| `result_full_segmentation.png` | 全图逐块 Otsu 分割结果 |
| `result_I_bw.png` | 最终二值图 |
--- ---
## 两版对比 ## Web 应用功能
| | 简化版 | 原版 | - **图像上传**:支持 PNG、JPG、TIFF 等格式,最大 50MB
|---|---|---| - **自动处理**:网格划分 + 逐格分割 + 后处理
| 功能 | 仅画网格线 | 网格 + 分割 + 去噪 | - **6 张可视化图表**
| 核心算法 | 加减乘除 | 自相关 + 形态学 + Otsu | 1. 网格线叠加原图
| 代码行数 | 150 | 430 | 2. 列投影曲线
| 检测网格线数 | 22×22 | 22×22 | 3. 行投影曲线
| 线条位置 | 42, 77, 112, … | 42, 77, 112, … | 4. 灰度直方图 + Otsu 阈值
| **误差** | **0 像素** | — | 5. 分割结果(后处理前)
6. 后处理结果
- **统计信息**:检测到的斑点数、Otsu 阈值、网格尺寸等
--- ---
## 技术文档
- `docs/gridding_simple_tutorial.md` — 简化版 190 行逐行讲解,含 ASCII 图解
- `results_simple/flowchart.drawio` — 算法流程图,用 Draw.io 打开
## 输入数据 ## 输入数据
`cDNA.png`820×820 RGB,来自 GEO 数据库 GSM16390Cy3/Cy5 双色荧光) - `cDNA.png`820×820 RGB,来自 GEO 数据库 GSM16390Cy3/Cy5 双色荧光)
- 23×23 斑点阵列,斑点间距约 35px,每斑点约 18px 宽
---
## 打包说明
使用 PyInstaller 将 Web 应用打包为单个 exe 文件:
```bash
python build_exe.py
# 输出:dist/cDNA_Analyzer.exe(约 68MB
```
打包配置已优化,排除了 torch、pandas 等无关重量级包。
---
## 运行环境 ## 运行环境
Python 3.10 + numpy, scipy, scikit-image, matplotlib, Pillow - **Python**: `D:\ProgramData\anaconda3\envs\my_env`
- **依赖**: numpy, scipy, scikit-image, matplotlib, Pillow, Flask
---
## 参考文献 ## 参考文献
- 芦碧波等. 低对比度 cDNA 图像分割的局部水平集方法. 中国图象图形学报, 2014. - 芦碧波等. 低对比度 cDNA 图像分割的局部水平集方法. 中国图象图形学报, 2014.
- 芦碧波. 高污染基因芯片图像的网格划分. 河南理工大学学报, 2019. - 芦碧波. 高污染基因芯片图像的网格划分. 河南理工大学学报, 2019.
---
## 仓库
- **首选**(局域网 SSH: `ssh://git@192.168.5.8:2222/Serendipity/cDNA-image-processing.git`
- **备选**(外网 HTTPS: `https://lhy-git.liuhangyv.top/Serendipity/cDNA-image-processing.git`
+38
View File
@@ -0,0 +1,38 @@
"""
PyInstaller打包脚本
运行: python build_exe.py
输出: dist/cDNA_Analyzer.exe
"""
import PyInstaller.__main__
import os
PyInstaller.__main__.run([
'web/launcher.py',
'--name=cDNA_Analyzer',
'--onefile',
'--noconsole',
'--add-data', f'web/templates;templates',
'--add-data', f'web/static;static',
# 排除不相关的重量级包(环境里有torch/pandas等)
'--exclude-module', 'torch',
'--exclude-module', 'torchvision',
'--exclude-module', 'torchaudio',
'--exclude-module', 'pandas',
'--exclude-module', 'sklearn',
'--exclude-module', 'sqlalchemy',
'--exclude-module', 'pygame',
'--exclude-module', 'zmq',
'--exclude-module', 'IPython',
'--exclude-module', 'jedi',
'--exclude-module', 'numba',
'--exclude-module', 'llvmlite',
'--exclude-module', 'onnxruntime',
'--exclude-module', 'lxml',
'--exclude-module', 'cryptography',
'--exclude-module', 'bcrypt',
'--exclude-module', 'pygments',
'--clean',
'--noconfirm',
])
print('\n完成!exe 在 dist/cDNA_Analyzer.exe')
@@ -1,4 +1,4 @@
# cDNA 微阵列网格划分 —— 简化版逐行代码讲解 # cDNA 微阵列网格划分 —— 逐行代码讲解
> 作者:刘航宇 | 河南理工大学计算机学院 > 作者:刘航宇 | 河南理工大学计算机学院
@@ -7,11 +7,11 @@
## 写在前面 ## 写在前面
这篇教程将 **逐行** 讲解 `cDNA_gridding_simple.py` 的每一段代码。 这篇教程将 **逐行** 讲解 `cDNA_gridding_simple.py` 的每一段代码。
目标读者是上《图像处理》课的同学,只要求学过 Python 基础(列表、循环、numpy 数组)。 目标读者是上《图像处理》课的同学,只要求学过 Python 基础(列表、循环、numpy 数组)。
读完这篇文章,你会彻底理解: 读完这篇文章,你会彻底理解:
- 一张 cDNA 微阵列图像是怎么被自动划分成 22×22 个方格子的 - 一张 cDNA 微阵列图像是怎么被自动划分成 22×22 个方格子的
- 为什么这么简单的算法,结果能和原版完全一致(误差 0 像素) - 为什么这么简单的算法,结果能和原版老师发的示范MATLAB代码效果完全一致(误差 0 像素)
--- ---
@@ -20,8 +20,7 @@
我们有一张 cDNA 芯片图像(820×820 像素),上面整齐排列着绿色的荧光斑点。 我们有一张 cDNA 芯片图像(820×820 像素),上面整齐排列着绿色的荧光斑点。
目标:**画一个网格线,让每个斑点都被一个方框圈住**。 目标:**画一个网格线,让每个斑点都被一个方框圈住**。
![整体思路](gridding_simple.png)
![整体思路](images/overview_idea.png)
怎么自动做到?核心思路只有一句话: 怎么自动做到?核心思路只有一句话:
@@ -305,8 +304,8 @@ is_positive = [False, True, True, True, False, True, True, True]
位置: 0 1 2 3 4 5 6 7 位置: 0 1 2 3 4 5 6 7
数值: -20 20 30 25 -22 15 40 18 数值: -20 20 30 25 -22 15 40 18
正负: False True True True False True True True 正负: False True True True False True True True
↑ ↑ ↑ ↑ ↑
变化(0→1) 变化(3→4) 变化(4→5) 变化(0→1) 变化(3→4) 变化(4→5)
``` ```
`is_positive[i] != is_positive[i-1]` 为 True 时,说明第 `i` 个像素处发生了正负翻转,也就是"跨过了零点"。 `is_positive[i] != is_positive[i-1]` 为 True 时,说明第 `i` 个像素处发生了正负翻转,也就是"跨过了零点"。
@@ -339,7 +338,7 @@ is_positive = [False, True, True, True, False, True, True, True]
``` ```
减阈值后的信号: 减阈值后的信号:
───── + + + + ────── + + + + ────── + + + + ───── ───── + + + + ────── + + + + ────── + + + + ─────
↑ 斑点 ↑ ↑ 斑点 ↑ ↑ 斑点 ↑ ↑ 斑点 ↑ ↑ 斑点 ↑ ↑ 斑点 ↑
c1 c2 c3 c4 ... c1 c2 c3 c4 ...
c1: 负→正 (进入第一个斑点) ← 开头是负,跳过它 c1: 负→正 (进入第一个斑点) ← 开头是负,跳过它
+101
View File
@@ -0,0 +1,101 @@
# 简化版 vs 原版 差异说明
> 写给以后的自己:简化版只是课堂演示用的,生产环境用原版。
---
## 一句话总结
| | 功能 | 用途 |
|---|---|---|
| 简化版 | 只画网格线 | 课堂讲解 |
| 原版 | 网格 + 去噪 + 分割 + 后处理 + 可视化 | 完整作业 |
---
## 简化版缺少的模块
### 1. TV全变分去噪
**位置**`cDNA_segmentation.py` 第 78-102 行,`tv_denoise()`
**干什么**:对每个子块做 Chambolle 投影去噪,去除荧光噪点。简化版完全没有这一步。
**实现**Rudin-Osher-Fatemi 模型,`min TV(u) + λ/2||f-u||²`,迭代对偶变量 p1、p2 直到收敛。
### 2. 三种阈值分割
**位置**`cDNA_segmentation.py` 第 35-71 行
| 函数 | 原理 |
|------|------|
| `manual_threshold()` | 灰度 > 固定值 T |
| `otsu_threshold()` | 最大类间方差自动求 T |
| `iterative_threshold()` | 初始 T=均值,迭代 T=(μ_fg+μ_bg)/2 直到收敛 |
简化版完全没有分割这一步。
### 3. 全图逐块分割
**位置**`cDNA_segmentation.py` 第 374-411 行
**做什么**:对网格划分出的每个子块:
1. 如果太暗(均值<5或<30),先增强
2. TV 去噪
3. Otsu 阈值二值化
4. 保留最大连通域
5. 拼接回全局二值图
简化版只画线,不处理子块。
### 4. 后处理
**位置**`cDNA_segmentation.py` 第 206-226 行
| 函数 | 作用 |
|------|------|
| `remove_small_objects()` | 去掉面积 < 20 的连通域(噪声) |
| `keep_largest_object()` | 每个子块只保留最大的那块(斑点) |
简化版不需要后处理(因为没生成二值图)。
### 5. 可视化差异
**简化版输出**1张):
| 文件 | 内容 |
|------|------|
| `gridding_simple.png` | 灰度图 + 绿色网格线 |
**原版输出**6张):
| 文件 | 内容 |
|------|------|
| `result_gridding.png` | 4合1:网格叠加 + 列投影 + 行投影 + 灰度直方图 |
| `result_gridding_overlay.png` | 网格线叠在原图上 |
| `result_threshold_compare.png` | 2×2:原图 + 人工/Otsu/迭代三种分割对比 |
| `result_iterative_convergence.png` | 迭代阈值收敛曲线 |
| `result_full_segmentation.png` | 全图逐块分割二值结果 |
| `result_I_bw.png` | 单独的二值图 |
### 6. 网格划分算法不同
| 步骤 | 简化版 | 原版 |
|------|--------|------|
| 投影 | `np.sum` 求和 | `np.mean` 求均值 |
| 估间距 | 无 | 自相关 `np.correlate` |
| 去背景 | 无 | `ndimage.white_tophat` |
| 找斑点 | 减10%阈值 → 过零点 | Otsu + 连通域质心 |
结果一致(误差 0 像素),但算法完全不同。
---
## 什么时候用哪个
| 场景 | 用哪个 |
|------|--------|
| 课堂讲算法思路 | 简化版 |
| 实际做图像处理 | 原版 |
| 对比阈值方法对同一张图的效果 | 原版 |
| 只画网格线 | 都可以 |
+36
View File
@@ -0,0 +1,36 @@
# 依赖库清单
## 核心运行时依赖
| 库名 | 版本 | 用途 | 哪些文件用到 |
|------|------|------|-------------|
| **numpy** | 1.26.4 | 数组运算、投影求和、统计 | 全部 .py |
| **scipy** | 1.15.3 | 连通域标记 `ndimage.label` | 全部 .py |
| **scikit-image** | 0.25.2 | `rgb2gray` 灰度转换、Otsu 阈值 | 全部 .py |
| **matplotlib** | 3.10.8 | 可视化绘图(投影曲线、直方图、结果图) | 全部 .py |
| **Pillow** | 12.1.1 | 读取图像文件(png/tif/jpg | 全部 .py |
| **Flask** | 3.1.2 | Web 后端 | web/app.py |
## 仅打包时需要的额外依赖
| 库名 | 用途 |
|------|------|
| **PyInstaller** | 将 Python 项目打包成独立 exe |
## 安装命令
```bash
pip install numpy scipy scikit-image matplotlib Pillow flask
```
如需打包:
```bash
pip install pyinstaller
python build_exe.py
```
## 版本兼容性
以上版本为当前开发环境(Python 3.10Anaconda `my_env`)实测版本。
其他相近版本通常兼容,无特殊版本锁定需求。
+109
View File
@@ -0,0 +1,109 @@
<mxfile host="65bd71144e">
<diagram name="简化版完整算法流程" id="flowchart">
<mxGraphModel dx="1475" dy="1478" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1200" pageHeight="2000" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="title" value="cDNA微阵列网格划分 —— 简化版完整算法流程" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=18;fontStyle=1;fontColor=#1565C0" parent="1" vertex="1">
<mxGeometry x="250" y="20" width="700" height="40" as="geometry"/>
</mxCell>
<mxCell id="s1" value="1. 读取图像" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#C8E6C9;strokeColor=#2E7D32;strokeWidth=2;fontSize=14;fontStyle=1;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="480" y="80" width="240" height="45" as="geometry"/>
</mxCell>
<mxCell id="s2" value="2. 转为灰度图&#xa;color.rgb2gray(img)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#C8E6C9;strokeColor=#2E7D32;strokeWidth=2;fontSize=13;fontStyle=1;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="480" y="155" width="240" height="45" as="geometry"/>
</mxCell>
<mxCell id="s_otsu" value="3. Otsu 自动阈值&#xa;遍历0~255,选最小类内方差 T&#xa;pct = T / 255(自适应)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFF9C4;strokeColor=#F9A825;strokeWidth=3;fontSize=12;fontStyle=1;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="480" y="230" width="240" height="55" as="geometry"/>
</mxCell>
<mxCell id="s3L" value="3a. 横轴投影&#xa;np.sum(gray, axis=0)&#xa;每列灰度求和" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#E8EAF6;strokeColor=#3949AB;strokeWidth=2;fontSize=12;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="270" y="327" width="210" height="60" as="geometry"/>
</mxCell>
<mxCell id="s3R" value="3b. 纵轴投影&#xa;np.sum(gray, axis=1)&#xa;每行灰度求和" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#E8EAF6;strokeColor=#3949AB;strokeWidth=2;fontSize=12;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="720" y="327" width="210" height="60" as="geometry"/>
</mxCell>
<mxCell id="s4" value="4. 计算投影阈值 X&#xa;X = (max-min) × pct" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFF9C4;strokeColor=#F9A825;strokeWidth=3;fontSize=13;fontStyle=1;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="480" y="437" width="240" height="50" as="geometry"/>
</mxCell>
<mxCell id="s5" value="5. 曲线减去 X&#xa;正 = 斑点,负 = 空隙,零 = 分界线" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFF9C4;strokeColor=#F9A825;strokeWidth=3;fontSize=12;fontStyle=1;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="480" y="527" width="240" height="55" as="geometry"/>
</mxCell>
<mxCell id="s6" value="6. 找过零点,配对取空隙中点&#xa;离开斑点 + 进入下一斑点 → 划线位置" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#E3F2FD;strokeColor=#1565C0;strokeWidth=3;fontSize=12;fontStyle=1;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="480" y="617" width="240" height="55" as="geometry"/>
</mxCell>
<mxCell id="s7" value="7. 逐格 Otsu 分割&#xa;对每个格子独立算 Otsu 阈值,二值化" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFE0B2;strokeColor=#E65100;strokeWidth=2;fontSize=12;align=center;verticalAlign=middle;fontStyle=1" parent="1" vertex="1">
<mxGeometry x="480" y="717" width="240" height="55" as="geometry"/>
</mxCell>
<mxCell id="s8" value="8. keep_largest_object&#xa;每个格子只保留面积最大的连通域" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFE0B2;strokeColor=#E65100;strokeWidth=2;fontSize=12;align=center;verticalAlign=middle;fontStyle=1" parent="1" vertex="1">
<mxGeometry x="480" y="807" width="240" height="50" as="geometry"/>
</mxCell>
<mxCell id="s9" value="9. remove_small_objects&#xa;全局去噪:面积 &amp;lt; 中位数25% 自动剔除" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFE0B2;strokeColor=#E65100;strokeWidth=2;fontSize=12;align=center;verticalAlign=middle;fontStyle=1" parent="1" vertex="1">
<mxGeometry x="480" y="897" width="240" height="50" as="geometry"/>
</mxCell>
<mxCell id="s10" value="10. 统计斑点数&#xa;ndimage.label 标记连通域,过滤面积&amp;lt;10" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFE0B2;strokeColor=#E65100;strokeWidth=2;fontSize=12;align=center;verticalAlign=middle;fontStyle=1" parent="1" vertex="1">
<mxGeometry x="480" y="987" width="240" height="50" as="geometry"/>
</mxCell>
<mxCell id="s11" value="11. 输出 6 张可视化图片&#xa;网格 / 投影曲线 / 直方图 / 分割 / 后处理" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#C8E6C9;strokeColor=#2E7D32;strokeWidth=2;fontSize=12;align=center;verticalAlign=middle;fontStyle=1" parent="1" vertex="1">
<mxGeometry x="480" y="1077" width="240" height="55" as="geometry"/>
</mxCell>
<mxCell id="e1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1" parent="1" source="s1" target="s2" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1" parent="1" source="s2" target="s_otsu" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e3L" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1" parent="1" source="s_otsu" target="s3L" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e3R" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1" parent="1" source="s_otsu" target="s3R" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e4L" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1" parent="1" source="s3L" target="s4" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e4R" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1" parent="1" source="s3R" target="s4" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e5" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1" parent="1" source="s4" target="s5" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e6" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1" parent="1" source="s5" target="s6" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e7" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1;fontStyle=1" parent="1" source="s6" target="s7" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e8" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1;fontStyle=1" parent="1" source="s7" target="s8" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e9" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1;fontStyle=1" parent="1" source="s8" target="s9" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e10" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1;fontStyle=1" parent="1" source="s9" target="s10" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e11" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1;fontStyle=1" parent="1" source="s10" target="s11" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="legend_title" value="图例" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=14;fontStyle=1" parent="1" vertex="1">
<mxGeometry x="270" y="929" width="60" height="25" as="geometry"/>
</mxCell>
<mxCell id="leg1" value="输入/输出" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#C8E6C9;strokeColor=#2E7D32;strokeWidth=2;fontSize=11;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="270" y="964" width="100" height="28" as="geometry"/>
</mxCell>
<mxCell id="leg2" value="网格划线" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFF9C4;strokeColor=#F9A825;strokeWidth=2;fontSize=11;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="270" y="999" width="100" height="28" as="geometry"/>
</mxCell>
<mxCell id="leg3" value="过零点/配对" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#E3F2FD;strokeColor=#1565C0;strokeWidth=2;fontSize=11;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="270" y="1034" width="100" height="28" as="geometry"/>
</mxCell>
<mxCell id="leg4" value="投影计算" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#E8EAF6;strokeColor=#3949AB;strokeWidth=2;fontSize=11;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="270" y="1069" width="100" height="28" as="geometry"/>
</mxCell>
<mxCell id="leg5" value="分割/后处理" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFE0B2;strokeColor=#E65100;strokeWidth=2;fontSize=11;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="270" y="1104" width="100" height="28" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

-130
View File
@@ -1,130 +0,0 @@
<mxfile host="65bd71144e">
<diagram name="简化版网格划分算法流程" id="flowchart">
<mxGraphModel dx="888" dy="899" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1200" pageHeight="1600" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="title" value="cDNA微阵列网格划分 —— 简化版算法流程图" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=20;fontStyle=1;fontColor=#1565C0" parent="1" vertex="1">
<mxGeometry x="300" y="30" width="600" height="40" as="geometry"/>
</mxCell>
<mxCell id="s1" value="1. 读取图像" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#C8E6C9;strokeColor=#2E7D32;strokeWidth=2;fontSize=14;fontStyle=1;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="480" y="100" width="240" height="50" as="geometry"/>
</mxCell>
<mxCell id="s2" value="2. 转为灰度图&#xa;color.rgb2gray(img)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#C8E6C9;strokeColor=#2E7D32;strokeWidth=2;fontSize=14;fontStyle=1;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="480" y="190" width="240" height="55" as="geometry"/>
</mxCell>
<mxCell id="s3L" value="3. 纵轴投影&#xa;np.sum(gray, axis=0)&#xa;每一列灰度值求和" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#E8EAF6;strokeColor=#3949AB;strokeWidth=2;fontSize=13;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="300" y="290" width="240" height="65" as="geometry"/>
</mxCell>
<mxCell id="s3R" value="3. 横轴投影&#xa;np.sum(gray, axis=1)&#xa;每一行灰度值求和" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#E8EAF6;strokeColor=#3949AB;strokeWidth=2;fontSize=13;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="660" y="290" width="240" height="65" as="geometry"/>
</mxCell>
<mxCell id="s4" value="4. 计算阈值 X&#xa;X = (max - min) × 10%" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFF9C4;strokeColor=#F9A825;strokeWidth=3;fontSize=14;fontStyle=1;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="480" y="400" width="240" height="55" as="geometry"/>
</mxCell>
<mxCell id="s5" value="5. 曲线减去 X&#xa;col_shifted = col_profile - X&#xa;row_shifted = row_profile - X" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFF9C4;strokeColor=#F9A825;strokeWidth=3;fontSize=13;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="480" y="500" width="240" height="65" as="geometry"/>
</mxCell>
<mxCell id="s6" value="6. 减阈值后结果" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFE0B2;strokeColor=#E65100;strokeWidth=2;fontSize=13;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="480" y="610" width="240" height="40" as="geometry"/>
</mxCell>
<mxCell id="d1" value="&gt; 0 ?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#FFF9C4;strokeColor=#F9A825;strokeWidth=2;fontSize=13;fontStyle=1;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="525" y="690" width="150" height="80" as="geometry"/>
</mxCell>
<mxCell id="s7pos" value="正 → 斑点区域" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#C8E6C9;strokeColor=#2E7D32;strokeWidth=2;fontSize=13;fontStyle=1;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="270" y="705" width="160" height="50" as="geometry"/>
</mxCell>
<mxCell id="s7neg" value="负 → 空隙" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFCDD2;strokeColor=#C62828;strokeWidth=2;fontSize=13;fontStyle=1;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="760" y="705" width="160" height="50" as="geometry"/>
</mxCell>
<mxCell id="s8" value="7. 找过零点" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#E3F2FD;strokeColor=#1565C0;strokeWidth=3;fontSize=14;fontStyle=1;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="480" y="820" width="240" height="50" as="geometry"/>
</mxCell>
<mxCell id="note1" value="遍历 is_positive 数组&#xa;当相邻位置正负不同时&#xa;记录该位置 = 过零点" style="shape=note;whiteSpace=wrap;html=1;fillColor=#FFFDE7;strokeColor=#F9A825;strokeWidth=1;fontSize=11;align=center;verticalAlign=middle;backgroundOutline=1;size=14" parent="1" vertex="1">
<mxGeometry x="800" y="820" width="160" height="80" as="geometry"/>
</mxCell>
<mxCell id="s9" value="8. 配对过零点" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#E3F2FD;strokeColor=#1565C0;strokeWidth=3;fontSize=14;fontStyle=1;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="480" y="920" width="240" height="50" as="geometry"/>
</mxCell>
<mxCell id="note2" value="配对「离开斑点+进入下一斑点」&#xa;即:正→负 与 负→正&#xa;中点 = 空隙中央 = 划线位置" style="shape=note;whiteSpace=wrap;html=1;fillColor=#FFFDE7;strokeColor=#F9A825;strokeWidth=1;fontSize=11;align=center;verticalAlign=middle;backgroundOutline=1;size=14" parent="1" vertex="1">
<mxGeometry x="800" y="920" width="180" height="80" as="geometry"/>
</mxCell>
<mxCell id="s10" value="9. 画出网格线&#xa;ax.axvline(x) / ax.axhline(y)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#C8E6C9;strokeColor=#2E7D32;strokeWidth=2;fontSize=13;fontStyle=1;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="480" y="1020" width="240" height="55" as="geometry"/>
</mxCell>
<mxCell id="s11" value="10. 输出网格叠加图" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#C8E6C9;strokeColor=#2E7D32;strokeWidth=2;fontSize=14;fontStyle=1;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="480" y="1120" width="240" height="50" as="geometry"/>
</mxCell>
<mxCell id="e1" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1" parent="1" source="s1" target="s2" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e2L" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1" parent="1" source="s2" target="s3L" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e2R" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1" parent="1" source="s2" target="s3R" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e3L" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1" parent="1" source="s3L" target="s4" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e3R" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1" parent="1" source="s3R" target="s4" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e4" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1" parent="1" source="s4" target="s5" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e5" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1" parent="1" source="s5" target="s6" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e6" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1" parent="1" source="s6" target="d1" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e7y" value="是" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#2E7D32;strokeWidth=2;endArrow=classic;endFill=1;fontColor=#2E7D32;fontStyle=1;fontSize=12" parent="1" source="d1" target="s7pos" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e7n" value="否" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#C62828;strokeWidth=2;endArrow=classic;endFill=1;fontColor=#C62828;fontStyle=1;fontSize=12" parent="1" source="d1" target="s7neg" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e8p" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1;dashed=1" parent="1" source="s7pos" target="s8" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="350" y="845"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="e8n" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1;dashed=1" parent="1" source="s7neg" target="s8" edge="1">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="840" y="790"/>
<mxPoint x="600" y="790"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="e8" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1" parent="1" source="s8" target="s9" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e9" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1" parent="1" source="s9" target="s10" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e10" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;strokeWidth=2;endArrow=classic;endFill=1" parent="1" source="s10" target="s11" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="legend_title" value="图例" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=14;fontStyle=1" parent="1" vertex="1">
<mxGeometry x="270" y="952.5" width="60" height="30" as="geometry"/>
</mxCell>
<mxCell id="leg1" value="输入/输出" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#C8E6C9;strokeColor=#2E7D32;strokeWidth=2;fontSize=11;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="270" y="992.5" width="100" height="30" as="geometry"/>
</mxCell>
<mxCell id="leg2" value="数据处理" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFF9C4;strokeColor=#F9A825;strokeWidth=2;fontSize=11;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="270" y="1032.5" width="100" height="30" as="geometry"/>
</mxCell>
<mxCell id="leg3" value="核心逻辑" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#E3F2FD;strokeColor=#1565C0;strokeWidth=2;fontSize=11;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="270" y="1072.5" width="100" height="30" as="geometry"/>
</mxCell>
<mxCell id="leg4" value="投影计算" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#E8EAF6;strokeColor=#3949AB;strokeWidth=2;fontSize=11;align=center;verticalAlign=middle" parent="1" vertex="1">
<mxGeometry x="270" y="1112.5" width="100" height="30" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

+289 -110
View File
@@ -1,20 +1,45 @@
""" """
cDNA微阵列图像处理 —— 简化版网格划分 cDNA微阵列图像处理 —— 简化版
====================================== ======================================
算法步骤(适合课堂讲解): D:\ProgramData\anaconda3\envs\my_env\python.exe src/cDNA_gridding_simple.py
1. 彩色图 → 灰度图 一、算法流程总览
2. 横轴投影:对每一列的所有像素灰度值求和 → 得到一条曲线
纵轴投影:对每一行的所有像素灰度值求和 → 得到一条曲线 灰度图 ──→ Otsu求像素最佳阈值 T ──→ 百分比 = T/255(自适应)
3. 在曲线上,求出 max 和 min,阈值 X = (max - min) × 10%
4. 曲线上每个值都减去 X ├─→ 投影/减阈值/过零点配对 ──→ 网格线
5. 减完之后:
- 大于 0 的地方 = 斑点区域 ├─→ 逐格 Otsu 分割 ──→ keep_largest_object(每格留最大块)
- 小于 0 的地方 = 斑点之间的空隙
- 等于 0 的地方 = 斑点与空隙的分界线(过零点) └─→ remove_small_objects(中位数25%以下判为噪声)──→ 统计斑点数
6. 配对相邻的过零点(离开斑点 + 进入下一个斑点),
中点就是空隙的中心 = 划线位置 二、各步骤详解
1. 彩色图 → 灰度图
2. Otsu 自动阈值
遍历灰度 0~255,每个候选 T 将像素分为前景(>T)和背景(≤T),
计算类内方差 w_bg×σ²_bg + w_fg×σ²_fg,选使方差最小的 T。
3. 投影
横轴:np.sum(每列) → 曲线,高点=斑点列,低点=空隙列
纵轴:np.sum(每行) → 曲线,高点=斑点行,低点=空隙行
4. 阈值 X = (max-min) × (T/255)
5. 曲线减 X → 大于 0 = 斑点区域,小于 0 = 空隙
过零点 = 斑点和空隙的分界线
6. 过零点配对
过零点交替:正→负(离开斑点)、负→正(进入下一斑点)
配对「离开斑点 + 进入下一斑点」,中点 = 空隙中央 = 划线位置
7. 逐格分割 + 后处理
对每个格子独立做 Otsu → keep_largest_object(留最大块)
→ 全局 remove_small_objects(自动去噪)→ 统计斑点数
8. 输出三栏图:左=网格,中=分割,右=后处理结果
""" """
import os import os
@@ -22,124 +47,121 @@ import numpy as np
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
from PIL import Image from PIL import Image
from skimage import color from skimage import color
from scipy import ndimage
# matplotlib 中文字体设置
plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei'] plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei']
plt.rcParams['axes.unicode_minus'] = False plt.rcParams['axes.unicode_minus'] = False
# 路径设置 # 路径设置(从脚本位置动态推导,禁止硬编码绝对路径)
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
BASE_DIR = os.path.dirname(SCRIPT_DIR) BASE_DIR = os.path.dirname(SCRIPT_DIR)
DATA_DIR = os.path.join(BASE_DIR, 'cDNA图像处理实例', '数据', 'cDNA') DATA_DIR = os.path.join(BASE_DIR, 'cDNA图像处理实例', '数据', 'cDNA')
OUTPUT_DIR = os.path.join(BASE_DIR, 'results_simple') OUTPUT_DIR = os.path.join(BASE_DIR, 'results_simple')
def draw_grid_lines(gray: np.ndarray, pct: float = 0.10): # ================================================================
# 函数1Otsu 像素级阈值
# ================================================================
def otsu_threshold_pixels(gray: np.ndarray) -> int:
""" """
核心算法:检测网格分割线 对图像像素做 Otsu 自动阈值检测
原理 遍历灰度值 0~255,对每个候选 T
灰度图的每一列/行,属于斑点的像素灰度值高,属于背景的灰度值低。 - 将像素分为两组:前景(>T) 和 背景(≤T)
把每列/行的灰度值加起来,就能得到一条曲线: - 计算类内方差 = w_bg × σ²_bg + w_fg × σ²_fg
——曲线凸起的地方 = 斑点所在位置 - 选使类内方差最小的 T
——曲线凹陷的地方 = 斑点之间的空隙
去掉一个阈值后,曲线在空隙处会变成负数, 返回 T0~255 整数)。
过零点的位置就是斑点和空隙的分界线,
两个分界线中点就是划线位置。
参数:
gray: 灰度图 (高×宽)
pct: 阈值百分比,默认10%
返回:
(纵线x坐标列表, 横线y坐标列表)
""" """
best_T = 0 # 当前最佳阈值
best_cost = float('inf') # 当前最小类内方差
total = gray.size # 总像素数(用于算权重)
for T in range(1, 255):
# 按 T 分组
bg = gray[gray <= T] # 背景像素
fg = gray[gray > T] # 前景像素(斑点)
w_bg = len(bg) / total # 背景占比
w_fg = len(fg) / total # 前景占比
if w_bg == 0 or w_fg == 0:
continue # 某组为空(T 太极端),跳过
# 类内方差 = 加权平均方差
# 方差小 = 组内像素灰度接近 = 分组效果好
cost = w_bg * np.var(bg) + w_fg * np.var(fg)
if cost < best_cost:
best_cost = cost
best_T = T
return best_T
# ================================================================
# 函数2:网格划线
# ================================================================
def draw_grid_lines(gray: np.ndarray):
"""
检测网格分割线。
流程:
Otsu 求自适应百分比 → 列/行投影 → 减阈值 → 过零点配对 → 空隙中点
返回 (纵线, 横线, T, pct, 列投影, 行投影, 减阈值后的列投影, 减阈值后的行投影)
"""
T = otsu_threshold_pixels(gray) # 像素级最佳阈值
pct = T / 255.0 # 自适应百分比
H, W = gray.shape H, W = gray.shape
# ================================================================ # ---- 1. 横轴投影 ----
# 步骤1:横轴投影 —— 统计每一列的灰度总和
# ================================================================
# gray 是一个 H×W 的二维数组,gray[行, 列] 是某个像素的灰度值
# np.sum(gray, axis=0) 沿行方向求和 → 得到长度为 W 的一维数组
# 含义:每一列上所有像素的灰度值加起来
# 斑点所在列 → 亮像素多 → 和较大(曲线凸起)
# 空隙所在列 → 暗像素多 → 和较小(曲线凹陷)
col_profile = np.sum(gray, axis=0).astype(float) col_profile = np.sum(gray, axis=0).astype(float)
# ================================================================ # ---- 2. 纵轴投影 ----
# 步骤2:纵轴投影 —— 统计每一行的灰度总和
# ================================================================
# np.sum(gray, axis=1) 沿列方向求和 → 得到长度为 H 的一维数组
# 含义:每一行上所有像素的灰度值加起来
row_profile = np.sum(gray, axis=1).astype(float) row_profile = np.sum(gray, axis=1).astype(float)
# ================================================================ # ---- 3. 投影阈值 ----
# 步骤3:计算阈值 X = (max - min) × 10% col_T_val = (np.max(col_profile) - np.min(col_profile)) * pct
# ================================================================ row_T_val = (np.max(row_profile) - np.min(row_profile)) * pct
# max-min 是曲线的"振幅",取10%作为阈值
# 大于这个阈值的才是真正的斑点信号,小于的是噪声
col_T = (np.max(col_profile) - np.min(col_profile)) * pct
row_T = (np.max(row_profile) - np.min(row_profile)) * pct
# ================================================================ # ---- 4. 曲线减去阈值 ----
# 步骤4:曲线上所有值减去阈值 col_shifted = col_profile - col_T_val
# ================================================================ row_shifted = row_profile - row_T_val
# 减去阈值后:
# 原本在空隙处的值(本来就小)→ 变成负数
# 原本在斑点处的值(本来就大)→ 仍然为正数
# 等于0的位置 = 斑点与空隙的分界线 = 过零点
col_shifted = col_profile - col_T
row_shifted = row_profile - row_T
# ================================================================ # ---- 5. 过零点配对 → 空隙中线 ----
# 步骤5:找过零点,两两配对,中间点即划线位置
# ================================================================
def find_gap_lines(prof_shifted: np.ndarray) -> np.ndarray: def find_gap_lines(prof_shifted: np.ndarray) -> np.ndarray:
""" """
在减去阈值后的投影曲线上,找到每两个斑点之间的空隙中线 在减去阈值后的曲线上,配对过零点,取空隙中
图解说明(设阈值为30 原理图解:
原始曲线: ___/‾‾‾\___/‾‾‾‾\___/‾‾‾\___ 信号: ----++++----++++----++++
数值: 10 50 60 55 8 45 70 48 5 55 50 12 ↑ ↑ ↑ ↑
减阈值后: -20 20 30 25 -22 15 40 18 -25 25 20 -18 过零点配对:离开斑点 + 进入下一个斑点
正负: ─ + + + ─ + + + ─ + + ─ → 中点 = 空隙中央 = 划线位置
↑ ↑ ↑ ↑
过零点 过零点 过零点 过零点
过零点之间的区域:
第一个 + 区域 = 一个斑点 (过零点1 → 过零点2)
第一个 - 区域 = 空隙 (过零点2 → 过零点3) ← 我们要的!
第二个 + 区域 = 下一个斑点 (过零点3 → 过零点4)
划线位置 = 空隙(负数区域)的中点
= (离开斑点的过零点 + 进入下一个斑点的过零点) / 2
""" """
# 判断每个位置是正斑点还是负空隙 # 每个位置是正(斑点)还是负(空隙)
is_positive = prof_shifted > 0 is_positive = prof_shifted > 0
# 收集所有符号变化位置(过零点) # 收集符号变化位置(过零点)
crossings = [] crossings = []
for i in range(1, len(is_positive)): for i in range(1, len(is_positive)):
# 如果当前和前一个正负不同 → 发生了跨越零点 if is_positive[i] != is_positive[i - 1]: # 正负翻转
if is_positive[i] != is_positive[i - 1]:
crossings.append(i) crossings.append(i)
if len(crossings) < 2: if len(crossings) < 2: # 过零点不足
return np.array([]) return np.array([])
# 过零点交替:正→负,负→正,正→负,负→正…… # 过零点交替:正→负(离开斑点), 负→正(进入下一斑点)
# 我们要的是「空隙区域」的中点 → 配对「离开斑点 → 进入下一斑点 # 要配对的是"离开斑点 → 进入下一斑点",即空隙的两端
# 即:从第一个"正→负"开始配对 # 如果信号开头是负,跳过第一个 crossing
# 如果开头就是负值(图像左侧是空隙),第一个过零点是"负→正",
# 跳过它,从下一个"正→负"开始
start = 1 if not is_positive[0] else 0 start = 1 if not is_positive[0] else 0
lines = [] lines = []
for k in range(start, len(crossings) - 1, 2): for k in range(start, len(crossings) - 1, 2):
if k + 1 < len(crossings): if k + 1 < len(crossings):
# crossings[k]: 正→负(离开斑点) # crossings[k]: 正→负(离开斑点)
# crossings[k+1]: 负→正(进入下一斑点) # crossings[k+1]: 负→正(进入下一斑点)
# 中点 = 空隙中央 = 划线位置 # 中点 = 空隙中央 = 划线位置
mid = int((crossings[k] + crossings[k + 1]) / 2) mid = int((crossings[k] + crossings[k + 1]) / 2)
lines.append(mid) lines.append(mid)
@@ -149,40 +171,197 @@ def draw_grid_lines(gray: np.ndarray, pct: float = 0.10):
x_lines = find_gap_lines(col_shifted) x_lines = find_gap_lines(col_shifted)
y_lines = find_gap_lines(row_shifted) y_lines = find_gap_lines(row_shifted)
return x_lines, y_lines return x_lines, y_lines, T, pct, col_profile, row_profile, col_shifted, row_shifted, col_T_val, row_T_val
# ================================================================
# 函数3:后处理(完全自动,无需人工设定阈值)
# ================================================================
def keep_largest_object(binary: np.ndarray) -> np.ndarray:
"""
每个格子里只保留面积最大的连通域。
ndimage.label 给每个白色连通域编号 → 算面积 → 只留最大那块。
不需要设定任何阈值。
"""
labeled, num = ndimage.label(binary)
if num == 0:
return np.zeros_like(binary) # 全黑,直接返回
# 统计每个连通域的像素数
areas = [int(np.sum(labeled == i)) for i in range(1, num + 1)]
# 找面积最大的编号
max_idx = int(np.argmax(areas)) + 1
return (labeled == max_idx).astype(np.uint8)
# ================================================================
# 函数4:自动去除小连通域(噪声)
# ================================================================
def remove_small_objects(binary: np.ndarray) -> np.ndarray:
"""
自动去除小连通域(噪声)。
对连通域面积分布做 Otsu 阈值检测:
面积分布天然双峰——噪声区(几个像素) 和 真斑点区(几百像素)。
Otsu 自动找到两峰之间的最佳分界,小于该值的视为噪声。
换图换分辨率都自动适应,不需要手动调参。
"""
labeled, num = ndimage.label(binary)
if num == 0:
return binary
# 收集所有连通域的面积
areas = np.array([int(np.sum(labeled == i)) for i in range(1, num + 1)])
if len(areas) < 2:
return binary
# 对面积数组做 Otsu(与像素 Otsu 完全相同的原理)
# 将面积值当作"灰度",找到最小类内方差的分界点
best_T, best_cost, n_total = 0, float('inf'), len(areas)
for T in np.unique(areas):
small = areas[areas <= T] # 候选噪声组
large = areas[areas > T] # 候选真斑点组
w_s = len(small) / n_total
w_l = len(large) / n_total
if w_s == 0 or w_l == 0:
continue
cost = w_s * np.var(small) + w_l * np.var(large)
if cost < best_cost:
best_cost = cost
best_T = T
min_size = best_T # Otsu 自动找到的面积分界线
# 面积不达标的连通域整块置0
result = binary.copy()
for i in range(1, num + 1):
if int(np.sum(labeled == i)) < min_size:
result[labeled == i] = 0
return result
# ================================================================
# 主流程
# ================================================================
def main(): def main():
os.makedirs(OUTPUT_DIR, exist_ok=True) os.makedirs(OUTPUT_DIR, exist_ok=True)
# ---- 读取图像转为灰度 ---- # ---- 读取图像转为灰度 ----
img = np.array(Image.open(os.path.join(DATA_DIR, 'cDNA.png'))) img = np.array(Image.open(os.path.join(DATA_DIR, 'cDNA.png')))
# 原图 RGBA(红绿蓝+透明通道),取前三个通道转为灰度 # 原图 RGBA,取前三个通道转为 0~255 灰度
gray = (color.rgb2gray(img[:, :, :3]) * 255).astype(np.uint8) gray = (color.rgb2gray(img[:, :, :3]) * 255).astype(np.uint8)
# ---- 执行网格检测 ---- # ---- 1. 网格划线 ----
x_lines, y_lines = draw_grid_lines(gray) (x_lines, y_lines, T_otsu, pct,
col_prof, row_prof, col_shifted, row_shifted,
col_T_val, row_T_val) = draw_grid_lines(gray)
print(f"检测到 {len(x_lines)} 条纵线, {len(y_lines)} 条横线") print(f"检测到 {len(x_lines)} 条纵线, {len(y_lines)} 条横线")
print(f"Otsu 阈值: T={T_otsu}, 自适应百分比: {pct*100:.1f}%")
# ---- 在原图上划线并保存 ---- # ---- 2. 逐格分割 + 后处理 ----
fig, ax = plt.subplots(figsize=(8, 8)) bw_full = np.zeros_like(gray)
ax.imshow(gray, cmap='gray') for i in range(len(y_lines) - 1):
for j in range(len(x_lines) - 1):
r1, r2 = y_lines[i], y_lines[i + 1]
c1, c2 = x_lines[j], x_lines[j + 1]
blk = gray[r1:r2, c1:c2]
if blk.size == 0:
continue
T = otsu_threshold_pixels(blk)
bw_blk = (blk > T).astype(np.uint8)
bw_blk = keep_largest_object(bw_blk)
bw_full[r1:r2, c1:c2] = bw_blk
# 画纵向分割线(竖线) bw_clean = remove_small_objects(bw_full)
# ---- 3. 统计斑点 ----
labeled, num = ndimage.label(bw_clean)
spot_sizes = [int(np.sum(labeled == i)) for i in range(1, num + 1)]
valid = [s for s in spot_sizes if s >= 10]
print(f"检测到 {len(valid)} 个斑点")
# ---- 4. 可视化输出(每张图独立保存)----
# 图1:网格线叠加原图
fig1, ax1 = plt.subplots(figsize=(8, 8))
ax1.imshow(gray, cmap='gray')
for x in x_lines: for x in x_lines:
ax.axvline(x=x, color='lime', linewidth=0.5) ax1.axvline(x=x, color='lime', linewidth=0.5)
# 画横向分割线(横线)
for y in y_lines: for y in y_lines:
ax.axhline(y=y, color='lime', linewidth=0.5) ax1.axhline(y=y, color='lime', linewidth=0.5)
ax1.set_title(f'网格划分 ({len(x_lines)}x{len(y_lines)})', fontsize=13)
ax1.axis('off')
fig1.savefig(os.path.join(OUTPUT_DIR, '01_grid_overlay.png'), dpi=150, bbox_inches='tight')
plt.close(fig1)
ax.set_title(f'cDNA微阵列网格划分 ({len(x_lines)}×{len(y_lines)})', fontsize=14) # 图2:列投影曲线(带阈值线和过零点标记)
ax.axis('off') fig2, ax2 = plt.subplots(figsize=(10, 4))
xs = np.arange(len(col_prof))
ax2.plot(xs, col_prof, 'b-', linewidth=0.6, label='col profile')
ax2.axhline(y=col_T_val, color='orange', linestyle='--', linewidth=1,
label=f'threshold X={col_T_val:.0f}')
ax2.plot(xs, col_shifted, 'g-', linewidth=0.6, alpha=0.5, label='after -X')
ax2.fill_between(xs, 0, col_shifted, where=(col_shifted > 0), color='green', alpha=0.1)
ax2.fill_between(xs, 0, col_shifted, where=(col_shifted < 0), color='red', alpha=0.1)
zero_idx = np.where(np.diff(col_shifted > 0) != 0)[0]
for zi in zero_idx[:50]:
ax2.axvline(x=zi, color='purple', linewidth=0.3, alpha=0.5)
for xl in x_lines:
ax2.axvline(x=xl, color='red', linewidth=0.8, alpha=0.7)
ax2.set_title('col projection', fontsize=12)
ax2.set_xlabel('col')
ax2.legend(fontsize=8)
fig2.savefig(os.path.join(OUTPUT_DIR, '02_col_projection.png'), dpi=120, bbox_inches='tight')
plt.close(fig2)
out_path = os.path.join(OUTPUT_DIR, 'gridding_simple.png') # 图3:行投影曲线
fig.savefig(out_path, dpi=150, bbox_inches='tight') fig3, ax3 = plt.subplots(figsize=(10, 4))
plt.close(fig) ys = np.arange(len(row_prof))
print(f"保存: {out_path}") ax3.plot(row_prof, ys, 'b-', linewidth=0.6, label='row profile')
ax3.axvline(x=row_T_val, color='orange', linestyle='--', linewidth=1,
label=f'threshold X={row_T_val:.0f}')
ax3.plot(row_shifted, ys, 'g-', linewidth=0.6, alpha=0.5, label='after -X')
ax3.fill_betweenx(ys, 0, row_shifted, where=(row_shifted > 0), color='green', alpha=0.1)
ax3.fill_betweenx(ys, 0, row_shifted, where=(row_shifted < 0), color='red', alpha=0.1)
zero_idx_r = np.where(np.diff(row_shifted > 0) != 0)[0]
for zi in zero_idx_r[:50]:
ax3.axhline(y=zi, color='purple', linewidth=0.3, alpha=0.5)
for yl in y_lines:
ax3.axhline(y=yl, color='red', linewidth=0.8, alpha=0.7)
ax3.set_title('row projection', fontsize=12)
ax3.set_ylabel('row')
ax3.legend(fontsize=8)
fig3.savefig(os.path.join(OUTPUT_DIR, '03_row_projection.png'), dpi=120, bbox_inches='tight')
plt.close(fig3)
# 图4:灰度直方图 + Otsu 阈值
fig4, ax4 = plt.subplots(figsize=(8, 5))
ax4.hist(gray.ravel(), bins=50, color='gray', edgecolor='black', linewidth=0.3)
ax4.axvline(x=T_otsu, color='red', linestyle='--', linewidth=2,
label=f'Otsu T={T_otsu} (pct={pct*100:.1f}%)')
ax4.set_title('histogram + Otsu threshold', fontsize=12)
ax4.set_xlabel('gray value')
ax4.set_ylabel('pixel count')
ax4.legend()
fig4.savefig(os.path.join(OUTPUT_DIR, '04_histogram.png'), dpi=120, bbox_inches='tight')
plt.close(fig4)
# 图5:逐格 Otsu 分割(后处理前)
fig5, ax5 = plt.subplots(figsize=(8, 8))
ax5.imshow(bw_full, cmap='gray')
ax5.set_title('per-cell Otsu (before post-processing)', fontsize=13)
ax5.axis('off')
fig5.savefig(os.path.join(OUTPUT_DIR, '05_segmentation_raw.png'), dpi=150, bbox_inches='tight')
plt.close(fig5)
# 图6:后处理结果(最终二值图)
fig6, ax6 = plt.subplots(figsize=(8, 8))
ax6.imshow(bw_clean, cmap='gray')
ax6.set_title(f'post-processed ({len(valid)} spots)', fontsize=13)
ax6.axis('off')
fig6.savefig(os.path.join(OUTPUT_DIR, '06_post_processed.png'), dpi=150, bbox_inches='tight')
plt.close(fig6)
print(f"共保存 6 张图到: {OUTPUT_DIR}")
if __name__ == '__main__': if __name__ == '__main__':
+53 -13
View File
@@ -203,25 +203,48 @@ def gridding(gray: np.ndarray) -> tuple:
# 第四部分:后处理(参考choice.m, choosemaxobj.m # 第四部分:后处理(参考choice.m, choosemaxobj.m
# ============================================================ # ============================================================
def remove_small_objects(binary: np.ndarray, min_size: int = 20) -> np.ndarray: def remove_small_objects(binary: np.ndarray) -> np.ndarray:
"""去除面积小于min_size的连通域""" """
自动去除小连通域。
对连通域面积分布做 Otsu 阈值检测——面积天然双峰,
Otsu 自动找到噪声峰和真斑点峰之间的最佳分界,零人工参数。
"""
labeled, num = ndimage.label(binary) labeled, num = ndimage.label(binary)
if num == 0:
return binary
areas = np.array([int(np.sum(labeled == i)) for i in range(1, num + 1)])
if len(areas) < 2:
return binary
best_T, best_cost, n_total = 0, float('inf'), len(areas)
for T_val in np.unique(areas):
small = areas[areas <= T_val]
large = areas[areas > T_val]
w_s = len(small) / n_total
w_l = len(large) / n_total
if w_s == 0 or w_l == 0:
continue
cost = w_s * np.var(small) + w_l * np.var(large)
if cost < best_cost:
best_cost = cost
best_T = T_val
result = binary.copy() result = binary.copy()
for i in range(1, num + 1): for i in range(1, num + 1):
if np.sum(labeled == i) < min_size: if int(np.sum(labeled == i)) < best_T:
result[labeled == i] = 0 result[labeled == i] = 0
return result return result
def keep_largest_object(binary: np.ndarray, min_size: int = 20) -> np.ndarray: def keep_largest_object(binary: np.ndarray) -> np.ndarray:
"""只保留最大连通域""" """每个格子里只保留面积最大连通域(无需设定阈值)"""
labeled, num = ndimage.label(binary) labeled, num = ndimage.label(binary)
if num == 0: if num == 0:
return binary
areas = [int(np.sum(labeled == i)) for i in range(1, num + 1)]
max_area = max(areas)
if max_area < min_size:
return np.zeros_like(binary) return np.zeros_like(binary)
areas = [int(np.sum(labeled == i)) for i in range(1, num + 1)]
max_idx = int(np.argmax(areas)) + 1 max_idx = int(np.argmax(areas)) + 1
return (labeled == max_idx).astype(np.uint8) return (labeled == max_idx).astype(np.uint8)
@@ -396,11 +419,12 @@ def main() -> None:
bw_blk = (blk_denoised > T).astype(np.uint8) bw_blk = (blk_denoised > T).astype(np.uint8)
except ValueError: except ValueError:
bw_blk = np.zeros(blk.shape, dtype=np.uint8) bw_blk = np.zeros(blk.shape, dtype=np.uint8)
# 后处理:保留最大连通域 # 后处理:保留最大连通域(无需阈值)
bw_blk = keep_largest_object(bw_blk, min_size=8) bw_blk = keep_largest_object(bw_blk)
bw_full[r1:r2, c1:c2] = bw_blk bw_full[r1:r2, c1:c2] = bw_blk
bw_full = remove_small_objects(bw_full, min_size=20) # 全局去除小连通域(中位数的25%以下自动判为噪声)
bw_full = remove_small_objects(bw_full)
fig_full = plot_full_segmentation(gray, bw_full, "全图逐块Otsu分割结果") fig_full = plot_full_segmentation(gray, bw_full, "全图逐块Otsu分割结果")
fig_full.savefig(os.path.join(OUTPUT_DIR, 'result_full_segmentation.png'), dpi=150, bbox_inches='tight') fig_full.savefig(os.path.join(OUTPUT_DIR, 'result_full_segmentation.png'), dpi=150, bbox_inches='tight')
@@ -410,7 +434,23 @@ def main() -> None:
bw_img.save(os.path.join(OUTPUT_DIR, 'result_I_bw.png')) bw_img.save(os.path.join(OUTPUT_DIR, 'result_I_bw.png'))
print(" 保存: result_I_bw.png") print(" 保存: result_I_bw.png")
# ---- 步骤4: 网格叠加图 ---- # ---- 步骤4: 统计点数 ----
print("\n[步骤4] 统计斑点数量...")
labeled_spots, num_spots = ndimage.label(bw_full)
# 统计每个斑点的面积
spot_sizes = []
for i in range(1, num_spots + 1):
size = int(np.sum(labeled_spots == i))
spot_sizes.append(size)
spot_sizes = np.array(spot_sizes)
# 过滤极小噪声
valid_spots = spot_sizes[spot_sizes >= 10]
print(f" 检测到 {len(valid_spots)} 个斑点 (面积≥10像素)")
print(f" 斑点面积: 最小 {valid_spots.min()}, 最大 {valid_spots.max()}, "
f"均值 {valid_spots.mean():.1f}, 中位数 {np.median(valid_spots):.0f}")
print(f" 网格子块数 (理论值): {(len(x_grid)-1) * (len(y_grid)-1)}")
# ---- 步骤5: 网格叠加图 ----
if img.ndim == 3: if img.ndim == 3:
overlay = img[:, :, :3].copy() overlay = img[:, :, :3].copy()
else: else:
+232
View File
@@ -0,0 +1,232 @@
"""
cDNA微阵列图像处理 - Web UI (Flask)
=====================================
启动:python web/app.py
打包:python build_exe.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'))
# PyInstaller 打包后资源路径
if getattr(sys, 'frozen', False):
bundle_dir = sys._MEIPASS
template_dir = os.path.join(bundle_dir, 'templates')
static_dir = os.path.join(bundle_dir, 'static')
else:
template_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'templates')
static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
app = Flask(__name__, template_folder=template_dir, static_folder=static_dir)
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 = np.array([int(np.sum(L==i)) for i in range(1,n+1)])
if len(areas) < 2: return binary
best_T, best_cost, n_total = 0, float('inf'), len(areas)
for T in np.unique(areas):
s, l = areas[areas<=T], areas[areas>T]
w_s, w_l = len(s)/n_total, len(l)/n_total
if w_s==0 or w_l==0: continue
cost = w_s*np.var(s) + w_l*np.var(l)
if cost < best_cost: best_cost, best_T = cost, T
r = binary.copy()
for i in range(1, n+1):
if int(np.sum(L==i)) < best_T: 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__':
import threading
def open_browser():
os.startfile('http://localhost:5000')
threading.Timer(1.5, open_browser).start()
app.run(debug=True, port=5000)
+22
View File
@@ -0,0 +1,22 @@
"""cDNA Analyzer 启动器 — 供 PyInstaller 打包"""
import os, sys, threading
# PyInstaller 打包后资源路径
if getattr(sys, 'frozen', False):
base = sys._MEIPASS
else:
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 设置模板和静态文件目录
os.environ['TEMPLATE_DIR'] = os.path.join(base, 'templates')
os.environ['STATIC_DIR'] = os.path.join(base, 'static')
# 导入 Flask app
from app import app
def open_browser():
os.startfile('http://localhost:5000')
if __name__ == '__main__':
threading.Timer(1.5, open_browser).start()
app.run(debug=False, port=5000)
+202
View File
@@ -0,0 +1,202 @@
/* ============================================================
cDNA Lab Console — Clean Academic Theme
Deep navy + white panels + subtle blue accents
============================================================ */
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Inter:wght@400;500;600;700&display=swap');
:root {
--bg: #0c1524;
--bg-panel: #14233a;
--bg-card: #1a2d4a;
--border: #1e3354;
--border-light: #2a4570;
--accent: #4d8ef7;
--accent-dim: #2b5db8;
--accent-soft: rgba(77, 142, 247, 0.12);
--text: #8899b4;
--text-dim: #4e6280;
--text-bright: #d0ddf0;
--white: #f0f4fc;
--red: #f55050;
--red-soft: rgba(245, 80, 80, 0.12);
--green: #52c97d;
--font-mono: 'IBM Plex Mono', 'Courier New', monospace;
--font-ui: 'Inter', '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;
-webkit-font-smoothing: antialiased;
}
/* ---- Header ---- */
.header {
display: flex; justify-content: space-between; align-items: center;
padding: 20px 36px; border-bottom: 1px solid var(--border);
background: var(--bg-panel);
}
.header-left { display: flex; align-items: center; gap: 10px; }
.logo-bracket { color: var(--text-dim); font-size: 1.3rem; font-family: var(--font-mono); }
.logo-text {
font-family: var(--font-mono); font-size: 1.25rem; font-weight: 500;
color: var(--white); letter-spacing: 0.12em;
}
.logo-accent { color: var(--accent); font-weight: 600; }
.header-right { display: flex; align-items: center; gap: 10px; }
.status-dot {
width: 7px; height: 7px; border-radius: 50%; background: var(--green);
box-shadow: 0 0 0 3px rgba(82, 201, 125, 0.2);
}
.status-label { font-family: var(--font-mono); font-size: 0.75rem; color: var(--text-dim); letter-spacing: 0.15em; }
/* ---- Container ---- */
.container { max-width: 1120px; margin: 0 auto; padding: 36px 28px; }
/* ---- Upload Zone ---- */
.upload-section { margin-bottom: 32px; }
.upload-zone-wrapper { display: flex; flex-direction: column; gap: 16px; }
.upload-zone {
border: 2px dashed var(--border); border-radius: 10px;
padding: 52px 24px; text-align: center; cursor: pointer;
transition: all 0.25s ease; background: var(--bg-panel);
}
.upload-zone:hover {
border-color: var(--accent); background: var(--accent-soft);
}
.upload-zone.dragover { border-color: var(--accent); border-style: solid; background: var(--accent-soft); }
.upload-svg { color: var(--text-dim); margin-bottom: 14px; transition: color 0.25s; }
.upload-zone:hover .upload-svg { color: var(--accent); }
.upload-text {
font-family: var(--font-mono); font-size: 1.05rem; color: var(--white);
letter-spacing: 0.08em; margin-bottom: 6px;
}
.upload-cursor { display: inline-block; color: var(--accent); animation: blink 1s step-end infinite; }
@keyframes blink { 50% { opacity: 0; } }
.upload-sub { font-size: 0.85rem; color: var(--text-dim); }
.upload-preview { display: flex; flex-direction: column; align-items: center; gap: 16px; }
.upload-preview img {
max-width: 100%; max-height: 320px; border-radius: 8px;
border: 1px solid var(--border); object-fit: contain; background: #000;
}
.btn-process {
display: flex; align-items: center; gap: 8px;
padding: 12px 36px; border: none; border-radius: 6px;
background: var(--accent); color: #fff;
font-family: var(--font-mono); font-size: 0.9rem; font-weight: 500; letter-spacing: 0.1em;
cursor: pointer; transition: all 0.2s;
}
.btn-process:hover { background: var(--accent-dim); transform: translateY(-1px); }
/* ---- Status Bar ---- */
.status-bar {
display: flex; align-items: center; gap: 14px;
padding: 10px 18px; background: var(--bg-panel); border: 1px solid var(--border);
border-radius: 6px; margin-bottom: 28px;
}
.status-track { flex: 1; height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
.status-fill { height: 100%; width: 0%; background: var(--accent); transition: width 0.1s; }
.status-text { font-family: var(--font-mono); font-size: 0.75rem; color: var(--accent); letter-spacing: 0.12em; }
/* ---- Error ---- */
.error-msg {
background: var(--red-soft); border: 1px solid var(--red); color: var(--red);
padding: 14px 20px; border-radius: 6px; margin-bottom: 24px;
font-family: var(--font-mono); font-size: 0.85rem;
}
/* ---- Stats Panel ---- */
.stats-panel { display: flex; gap: 14px; flex-wrap: wrap; margin-bottom: 32px; }
.stat-card {
flex: 1; min-width: 150px; padding: 20px 24px;
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 8px;
text-align: center; transition: border-color 0.25s;
}
.stat-card:hover { border-color: var(--border-light); }
.stat-card.spot { border-color: var(--accent-dim); background: var(--accent-soft); }
.stat-label {
display: block; font-family: var(--font-mono); font-size: 0.68rem;
color: var(--text-dim); letter-spacing: 0.18em; margin-bottom: 8px; font-weight: 500;
}
.stat-value {
display: block; font-family: var(--font-mono); font-size: 1.5rem;
color: var(--white); font-weight: 500;
}
.stat-card.spot .stat-value { color: var(--accent); font-size: 2rem; font-weight: 600; }
/* ---- Gallery ---- */
.gallery { margin-bottom: 48px; }
.gallery-header {
display: flex; justify-content: space-between; align-items: center; margin-bottom: 22px;
}
.gallery-title {
font-family: var(--font-mono); font-size: 1rem; font-weight: 500;
color: var(--white); letter-spacing: 0.16em;
}
.btn-dl-all {
display: flex; align-items: center; gap: 6px;
padding: 9px 22px; border: 1px solid var(--border-light); border-radius: 6px;
background: transparent; color: var(--text);
font-family: var(--font-mono); font-size: 0.78rem; letter-spacing: 0.1em;
cursor: pointer; transition: all 0.2s;
}
.btn-dl-all:hover { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.btn-dl-all span { font-size: 1rem; }
.gallery-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
@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: 8px; overflow: hidden; background: var(--bg-card);
border: 1px solid var(--border);
transition: border-color 0.25s, transform 0.2s;
}
.gallery-card:hover { border-color: var(--border-light); transform: translateY(-2px); }
.gallery-card-inner {
aspect-ratio: 1; overflow: hidden; display: flex; align-items: center; justify-content: center;
background: #060c18; cursor: pointer;
}
.gallery-card-inner img {
width: 100%; height: 100%; object-fit: contain; transition: transform 0.3s;
}
.gallery-card-inner:hover img { transform: scale(1.04); }
.gallery-card-label {
padding: 10px 14px; 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); font-weight: 500;
}
/* ---- Lightbox ---- */
.lightbox {
display: none; position: fixed; inset: 0; z-index: 1000;
background: rgba(4, 10, 20, 0.95); flex-direction: column; align-items: center; justify-content: center;
}
.lightbox img {
max-width: 92vw; max-height: 88vh; object-fit: contain;
border-radius: 4px; border: 1px solid var(--border-light); background: #000;
}
.lightbox-close {
position: absolute; top: 24px; right: 36px;
font-size: 2rem; color: var(--text-dim); cursor: pointer; transition: color 0.2s;
line-height: 1;
}
.lightbox-close:hover { color: var(--white); }
.lightbox-dl {
margin-top: 18px; padding: 10px 28px; border: 1px solid var(--accent); border-radius: 6px;
color: var(--accent); text-decoration: none; font-family: var(--font-mono); font-size: 0.85rem;
transition: all 0.2s;
}
.lightbox-dl:hover { background: var(--accent); color: #fff; }
/* ---- Scrollbar ---- */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--border-light); }
+176
View File
@@ -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 &middot; 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>&#8595;</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">&times;</span>
<img id="lightboxImg" src="" alt="Full size">
<a id="lightboxDownload" class="lightbox-dl" download>&#8595;</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>