简介:基于深度学习的矿物显微图像智能分类项目,提供完整源码与说明文档,面向计算机相关专业学生、毕业设计或课程设计开发者。项目采用迁移学习思路,包含数据爬取、数据集划分、模型训练、评估与单张图像预测等完整流程,并实现将模型转换为TensorFlow.js格式,配合HTML/JS前端页面完成浏览器端实时演示。整个压缩包共15个文件,涵盖7个Python脚本、2个Jupyter Notebook、2个HTML页面及配套JS、README说明和网络结构示意图,大小仅1.52MB,结构清晰。Python脚本覆盖数据爬取、数据划分、训练、评估、预测等环节,Notebook适合逐段运行调试;前端与模型转换代码则展示了从离线训练到Web部署的完整链路。目前已有79人学习下载,既可作为机器学习入门实战,也可直接作为课程设计、项目立项演示或毕业设计的基础框架,具有一定的工程参考价值和可扩展性。
1. 矿物显微图像分类为什么值得用深度学习做
矿物显微图像分类是把偏光显微镜或扫描电镜下的矿物薄片图像,自动识别为石英、长石、方解石、辉石等类别。传统做法是地质工程师用肉眼观察颜色、解理、干涉色等特征,经验门槛高、主观性强。深度学习通过卷积神经网络直接学习图像纹理和结构特征,在多个公开矿物数据集上的准确率能超过90%,且推理速度远快于人工。这套源码包正好把完整链路都给了出来:从数据采集的spider_data.py,到训练脚本train.py,再到能转成TensorFlow.js部署到浏览器的convert_model_to_javascript.py,适合做课程设计、毕业设计,也适合想实际跑通一个图像分类项目的开发者。
2. 数据准备与预处理:spider_data.py 与 split_data.py 的工程化拆解
深度学习项目的成败很大程度取决于数据质量。矿物图像分类的数据集不像ImageNet那样随手可下载,通常需要自己采集和整理。这个项目里用spider_data.py来做数据抓取,再用split_data.py划分数据集,实现了从原始图像到训练集的完整流程。
2.1 用spider_data.py爬取矿物图像数据
spider_data.py的核心作用是从矿物图片网站或搜索引擎结果中批量抓取图像。常见的做法是用requests库请求图片列表页,解析出图片直链,再通过concurrent.futures多线程下载。下面的代码示意了这类脚本的骨架:
import requests import os from concurrent.futures import ThreadPoolExecutor def download_image(url, save_path): headers = {"User-Agent": "Mozilla/5.0"} try: r = requests.get(url, headers=headers, timeout=10) if r.status_code == 200 and len(r.content) > 2000: with open(save_path, "wb") as f: f.write(r.content) print(f"下载成功: {save_path}") else: print(f"跳过无效图片: {url}") except Exception as e: print(f"下载失败 {url}: {e}") def spider(mineral_name, urls, save_dir): os.makedirs(save_dir, exist_ok=True) with ThreadPoolExecutor(max_workers=8) as executor: for i, url in enumerate(urls): save_path = os.path.join(save_dir, f"{mineral_name}_{i}.jpg") executor.submit(download_image, url, save_path)这段代码的逻辑是先用os.makedirs建好类别目录,再把图片URL列表映射成本地文件路径,借助ThreadPoolExecutor把下载任务丢进8个线程并行执行。下载时判断响应码和文件大小,是为了过滤掉404页面和过小的占位图,避免把无效图片送进训练集。
参数上,timeout=10是连接超时,防止某个URL长时间挂起。max_workers=8控制并发数,如果目标网站有反爬,建议降到2或3,并加上time.sleep(random.uniform(0.5, 1.5))。len(r.content) > 2000这个阈值可以根据图像尺寸调整,通常显微镜照片都在几十KB以上,设置更保守的20KB也可以。
2.2 数据清洗与标签整理
爬下来的图片经常混有网页logo、重复截图、非目标矿物,所以需要清洗。项目里没有单独的清洗脚本,但spider_data.py在保存时按矿物名建目录,这本身就承担了给数据打标签的功能。目录结构如下:
data/ ../../../calcite/ ../../../feldspar/ ../../../quartz/ ../../../garnet/这种按类别分目录的组织方式是Keras的ImageDataGenerator和flow_from_directory直接支持的,目录名就是标签。清洗时可以直接人工浏览每个目录,把明显不对的图片删除;也可以用简单脚本计算所有图片的哈希值去重:
import hashlib from pathlib import Path from collections import defaultdict def find_duplicates(root_dir): hashes = defaultdict(list) for path in Path(root_dir).rglob("*.jpg"): with open(path, "rb") as f: digest = hashlib.md5(f.read()).hexdigest() hashes[digest].append(path) return {digit: paths for digit, paths in hashes.items() if len(paths) > 1}这个函数遍历所有.jpg文件,计算MD5摘要,把相同摘要的路径归到同一组。返回的字典里value长度大于1的组就是重复图片组,删除多余副本即可。注意MD5只对完全相同的文件有效,如果图片是同一张但被缩放或重压缩过,需要改用感知哈希,比如imagehash库的phash方法。
2.3 split_data.py划分训练集、验证集、测试集
一个容易踩的坑是随意划分数据集导致类别分布不一致。split_data.py的作用是按固定比例把每个类别的图片拆成三份。常见参数是训练集70%、验证集15%、测试集15%,并且设置随机种子保证可复现。核心逻辑如下:
import random import shutil from pathlib import Path def split_data(src_dir, train_dir, val_dir, test_dir, train_ratio=0.7, val_ratio=0.15, seed=42): random.seed(seed) for class_dir in Path(src_dir).iterdir(): if not class_dir.is_dir(): continue images = list(class_dir.glob("*.jpg")) + list(class_dir.glob("*.png")) random.shuffle(images) n_train = int(len(images) * train_ratio) n_val = int(len(images) * val_ratio) assign = {"train": images[:n_train], "val": images[n_train:n_train + n_val], "test": images[n_train + n_val:]} for split_name, file_list in assign.items(): target = Path(train_dir if split_name == "train" else val_dir if split_name == "val" else test_dir) / class_dir.name target.mkdir(parents=True, exist_ok=True) for img in file_list: shutil.copy(img, target / img.name) if __name__ == "__main__": split_data("data", "train_data", "val_data", "test_data")这段代码先对每个类别的图片列表做随机打乱,再按比例切片。shutil.copy而不是move,保留了原始数据,方便后续补充。随机种子seed=42很关键,否则每次运行划分结果都不同,模型复现和对比实验就无从谈起。
需要说明的是,划分前应当先确认每类图片数量足够。如果某个矿物图像只有30张,那么15%的验证集只有4张,评估结果方差会很大。项目里如果遇到这种情况,可以改用分层抽样,或者直接在训练时用validation_split从训练集内部再分,保证每个batch都覆盖所有类别。下表总结了常用参数参考值:
| 参数 | 建议值 | 说明 |
|---|---|---|
| train_ratio | 0.7 | 训练集比例,图像少于100张/类时可提到0.8 |
| val_ratio | 0.15 | 验证集比例,用于早停和模型选择 |
| seed | 42 | 随机种子,保证划分可复现 |
| max_workers | 8 | 下载线程数,反爬严格时降为2 |
| 最小文件阈值 | 2000字节 | 过滤无效响应的阈值,可调大 |
3. 模型训练与评估:从train.ipynb到evaluate_model.py的完整闭环
数据准备好之后,进入模型训练环节。项目里同时提供train.py和train.ipynb,前者适合命令行批量运行,后者适合在Jupyter里边改边看曲线。模型基于预训练的卷积神经网络做迁移学习,默认的InceptionV3或ResNet50都能在矿物图像上取得不错效果。inceptionV3_model.png展示了模型结构,训练和评估脚本则是完整的可执行闭环。
3.1 用ImageDataGenerator做数据增强
矿物显微图像在拍摄时存在光照不均、旋转角度随机、尺度变化等特点,但类别本身对旋转不敏感,所以很适合做旋转、翻转、缩放类的数据增强。train.py里典型做法是用tf.keras.preprocessing.image.ImageDataGenerator:
from tensorflow.keras.preprocessing.image import ImageDataGenerator train_datagen = ImageDataGenerator( rescale=1./255, rotation_range=20, width_shift_range=0.1, height_shift_range=0.1, shear_range=0.1, zoom_range=0.1, horizontal_flip=True, vertical_flip=True, fill_mode="reflect" ) val_datagen = ImageDataGenerator(rescale=1./255) train_generator = train_datagen.flow_from_directory( "train_data", target_size=(224, 224), batch_size=32, class_mode="categorical" ) val_generator = val_datagen.flow_from_directory( "val_data", target_size=(224, 224), batch_size=32, class_mode="categorical" )rescale=1./255把像素值从0-255归一化到0-1,是模型输入的必要步骤。rotation_range=20表示随机旋转不超过20度,width_shift_range和height_shift_range是平移比例,shear_range是错切变换,zoom_range是缩放范围。水平翻转和垂直翻转对于矿物图像都是安全的,因为显微镜下没有“上下”的概念。fill_mode="reflect"处理旋转后产生的空白区域,用镜像填充比填0更自然。
需要注意,验证集和测试集都不能做数据增强,只能做归一化。否则验证集的分布会偏离真实数据,评估结果不可信。flow_from_directory会自动读取每个子目录名作为类别,并按字母顺序排序,这一点要和训练时保持一致。
3.2 迁移学习与冻结参数
矿物图像数据集通常不大,从头训练一个深度网络很容易过拟合。项目里采用迁移学习,加载在ImageNet上预训练的InceptionV3模型,把顶部分类器替换成自定义全连接层。常见做法是冻结卷积基,只训练新加的分类层:
from tensorflow.keras.applications import InceptionV3 from tensorflow.keras.models import Model from tensorflow.keras.layers import GlobalAveragePooling2D, Dense, Dropout base_model = InceptionV3(weights="imagenet", include_top=False, input_shape=(224, 224, 3)) base_model.trainable = False x = base_model.output x = GlobalAveragePooling2D()(x) x = Dense(256, activation="relu")(x) x = Dropout(0.5)(x) predictions = Dense(num_classes, activation="softmax")(x) model = Model(inputs=base_model.input, outputs=predictions) model.compile(optimizer="adam", loss="categorical_crossentropy", metrics=["accuracy"])include_top=False扔掉了预训练模型自带的1000类分类器,只保留卷积基。trainable = False让卷积层参数固定不变,训练时只更新后面新加的层。GlobalAveragePooling2D把最后一层特征图压缩成向量,比直接Flatten参数量少、更抗过拟合。Dropout(0.5)随机丢弃一半神经元,是控制过拟合的常规操作。softmax输出每个矿物类别的概率。
如果训练精度上不去,可以解冻部分卷积层做微调。做法是把base_model.trainable设成True,然后用更小的学习率比如1e-5重新训练。微调时学习率必须比普通训练低,否则会破坏预训练学到的特征。
3.3 train.py的训练循环与回调
train.py里的训练逻辑通常包含ModelCheckpoint、EarlyStopping和ReduceLROnPlateau三个回调:
from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint, ReduceLROnPlateau callbacks = [ EarlyStopping(monitor="val_loss", patience=10, restore_best_weights=True), ModelCheckpoint("best_model.h5", monitor="val_accuracy", save_best_only=True), ReduceLROnPlateau(monitor="val_loss", factor=0.5, patience=3, min_lr=1e-6) ] history = model.fit( train_generator, steps_per_epoch=train_generator.samples // train_generator.batch_size, validation_data=val_generator, validation_steps=val_generator.samples // val_generator.batch_size, epochs=50, callbacks=callbacks )EarlyStopping监控验证集损失,连续10个epoch不下降就停止训练,并自动恢复到最佳权重。ModelCheckpoint只保存验证集准确率最好的模型,避免最后一轮过拟合模型覆盖最优结果。ReduceLROnPlateau在验证损失连续3个epoch不下降时把学习率乘0.5,最低降到1e-6,帮助损失跨过局部极小点。
steps_per_epoch和validation_steps需要设置为样本数除以batch_size。如果不设置,model.fit会用完整epoch的长度,在小数据集上问题不大,但在数据不整除时会漏掉尾部样本。train.ipynb则更适合直接查看训练曲线,通过plot history的损失和准确率判断模型是否收敛。
3.4 evaluate_model.py评估模型性能
训练完成后需要独立评估,而不是只看训练时的历史曲线。evaluate_model.py做的事情是用测试集计算损失、准确率、精确率、召回率、F1-score,并生成混淆矩阵。混淆矩阵对于矿物分类尤其重要,因为有些矿物在镜下外观很接近,比如石英和长石类矿物,模型可能系统性地混淆。代码示意:
from tensorflow.keras.models import load_model from sklearn.metrics import classification_report, confusion_matrix import numpy as np model = load_model("best_model.h5") y_true, y_pred = [], [] for images, labels in test_generator: probs = model.predict(images) y_pred.extend(np.argmax(probs, axis=1)) y_true.extend(np.argmax(labels, axis=1)) if len(y_true) >= test_generator.samples: break print(classification_report(y_true, y_pred, target_names=list(test_generator.class_indices.keys()))) print(confusion_matrix(y_true, y_pred))classification_report会输出每一类的精确率、召回率和F1,这三项指标在类别不平衡时比整体准确率更有参考价值。confusion_matrix是方阵,行是真实类别,列是预测类别,对角线越高说明分类越可靠。如果对角线上某类明显偏低,需要返回去检查这个类别的图片质量或数量。
evaluate_model.ipynb是对应的笔记本版本,方便在Jupyter里可视化混淆矩阵的热力图。建议在训练过程中定期跑一次评估,而不是等到训练彻底结束,这样能及早发现数据或标签问题。
4. 模型部署到浏览器:convert_model_to_javascript.py与tfjs实战
这是项目里比较有亮点的部分。很多图像分类项目止步于训练脚本,但这里提供了把模型转成TensorFlow.js并部署到浏览器的完整链路。这意味着地质工作人员不需要安装Python环境,打开网页就能上传矿物照片识别类别。
4.1 把Keras模型转换为TensorFlow.js格式
convert_model_to_javascript.py使用的是TensorFlow.js转换器。转换前需要确保本机安装了tensorflowjs包,然后执行加载模型并保存为web格式:
import tensorflowjs as tfjs tfjs.converters.save_keras_model( "best_model.h5", "tfjs_model" )这段代码会把best_model.h5转换成tfjs_model目录下的model.json和一组分片权重文件。model.json描述了模型结构和权重分片索引,浏览器通过fetch这个文件就能加载模型。保存时需要确保模型是完整的Keras .h5格式,如果的是SavedModel格式的目录,需要使用tfjs.converters.convert_tf_saved_model。
转换过程中最容易遇到的坑是自定义层或自定义损失函数。如果训练时用了自定义层,比如带权重的注意力层,转换器会报错。一般解决办法是在转换前重建一个纯Keras模型,只用标准层,训练和推理都走同样的结构。这个项目里的模型是InceptionV3加全连接层,所有层都是标准操作,转换通常一次通过。
4.2 main.js加载模型并进行预测
转换完成后,前端通过main.html展示界面,把部分逻辑封装在main.js里。加载模型和预处理图像的代码大致是这样:
let model; async function loadModel() { model = await tf.loadLayersModel("tfjs_model/model.json"); console.log("模型加载完成"); } function preprocessImage(imageElement) { const tensor = tf.browser.fromPixels(imageElement) .resizeBilinear([224, 224]) .toFloat() .div(tf.scalar(255.0)) .expandDims(0); return tensor; } async function predictImage() { const tensor = preprocessImage(document.getElementById("inputImage")); const probs = await model.predict(tensor).data(); const classes = ["calcite", "feldspar", "quartz", "garnet"]; const argMax = probs.indexOf(Math.max(...probs)); document.getElementById("result").innerText = "预测类别: " + classes[argMax] + " 置信度: " + probs[argMax].toFixed(4); tensor.dispose(); }tf.loadLayersModel从model.json加载模型,返回的是可调用的模型对象。tf.browser.fromPixels把HTML的ImgElement或Canvas变成张量。resizeBilinear把图像缩放到训练时的224x224尺寸,注意这里用的是双线性插值,和Keras的target_size默认缩放方式一致。归一化除以255,和训练时的rescale=1./255完全对应。expandDims(0)增加batch维度,因为模型期望输入是[batch, height, width, channels]。
model.predict返回的是概率数组,Math.max(...probs)找到最大概率的索引,再从类别列表里取出名称。这里有一个容易漏掉的细节:类别列表的顺序必须和训练时flow_from_directory里的class_indices顺序一致,否则预测结果会张冠李戴。训练时可以用train_generator.class_indices打印对应的映射表。
tensor.dispose()是TensorFlow.js中释放显存/内存的重要步骤。浏览器环境下内存泄漏往往来自张量没有清理,长时间运行页面会卡死。每次预测完都要手动dispose,中间产生的resize结果如果不复用也要一并释放。
4.3 main.html与linear.html的工程取舍
main.html是主要的交互页面,一般包含一个文件上传控件和一个canvas,用来预览选中的图像。上传后用URL.createObjectURL生成临时链接并赋值给img,图片加载完成后调用predictImage。这里需要注意模型的加载是异步的,用户首次点击预测时模型可能还没有加载完毕,所以要做一个await loadModel()的初始化流程。
linear.html则是一个更轻量级的演示页,它可能只包含一个线性层的简化模型,或者直接展示模型运行的日志。两个html分开的意义在于:main.html适合完整演示,给非技术人员看;linear.html适合调试模型输出时使用,把每个步骤的结果打印到页面上,方便确认预处理链路是否正确。对于熟悉前端调试的人来说,线性版本反而更容易定位是前端张量处理的问题还是模型本身的问题。
5. 进阶技巧与常见踩坑
最后一章写几个在实践中真正会用到的问题和对应处理方式。
5.1 类别不平衡时改用加权损失
如果某类矿物图像数量特别少,整体准确率会虚高。常见做法是在Model.compile时给每个类别一个权重,让少数类的loss被放大。使用class_weight参数可以做到:
class_weights = {0: 1.0, 1: 2.0, 2: 1.5, 3: 3.0} model.fit(..., class_weight=class_weights)权重设置一般按每类样本数的反比来算,比如样本数最小的类别权重设为3,最大的类别设为1。但要注意权重过大会导致模型对少数类过拟合,所以需要观察验证集F1而不是单纯看准确率。如果数据集里某类只有不到20张图,光靠权重还不够,建议增加这类图像的数量,或者用更重的数据增强只针对该类别。
5.2 检查测试集和训练集的图像尺寸分布
显微镜图像的分辨率可能不一致,有些是1024x1024,有些是512x512。训练时统一resize到224x224会把小图放大,导致细节模糊。建议在split_data后先统计所有图像的分辨率,把过小的图过滤掉或单独处理。如果出现某类图像全是小分辨率,模型可能学到的是图像的模糊程度而不是矿物特征,这类伪特征在真实场景一测就露馅。统计尺寸可以直接用PIL:
from PIL import Image from pathlib import Path sizes = {} for path in Path("train_data").rglob("*.jpg"): with Image.open(path) as img: sizes[path.name] = img.size print(set(sizes.values()))如果发现尺寸分布差异大,可以在训练时启用target_size之外的随机缩放,或者先用resize把所有图片统一成更大尺寸再训练,比如先处理到512x512再喂给模型。当然这样会增加显存消耗。对于矿物图像,至少应保证最小边不小于224,否则关键解理纹理会被插值算法抹掉。
5.3 TF.js转换时遇到Unsupported Op怎么办
转换InceptionV3的卷积基一般不会出问题,但如果你后续加了实例归一化、自定义激活函数,转换器可能报Unsupported Ops。一个临时方案是改用TensorFlow.js 4.x的dynamic loading模式,把部分操作放到内联函数里实现。更推荐的做法是回到Keras模型结构检查一下,是否有用Lambda层直接写了原生代码的算子,有就换成Keras标准层。
另一个坑是模型权重文件过大。InceptionV3的模型转换后有几十MB,浏览器首次加载会比较慢。可以在转换时加权重压缩参数:
tfjs.converters.save_keras_model( model, "tfjs_model_quantized", quantization_dtype_map={"uint8": 1} )把权重量化到uint8,体积能减少约四分之一,精度损失通常在1%以内。注意某些浏览器对fetch大文件有并发限制,分片文件会被卡住,用tf.loadLayersModel的onProgress回调显示加载进度条会友好很多。
最后,验证整个链路是否一致的一个简单技巧:用训练集里一张图片,先通过test_model.py得到Python环境下的预测类别,再用同一张图在浏览器里跑一遍。如果两个结果不一致,说明前后端预处理差异还没有对齐,需要逐项检查resize方式、归一化顺序和通道顺序。通过这个对照,两分钟就能确定问题出在哪一侧。
本文还有配套的精品资源,点击获取