本组的选题是“牙科数据的分割与分类及可视化展示平台”。工作主要划分成三块:前端平台页面搭建、算法与数据可视化、后端及数据库。我和另一个同学一起负责算法与数据可视化。
根据学长给的demo视频。我们将组内任务分为如下:
(1)CBCT数据、口扫数据可视化(三维数据)
其中细分由分为如下功能:三维数据呈现、改变观测视角、光照效果、材质、颜色透明度变换,形成渲染器中的GUI。
(2)X光全景图可视化(二维数据)
细分功能如下:二维数据呈现、切换深度、基本的图像处理。
(1)牙齿分割任务
(2)牙齿分类任务
注:模型已经由学长给出,我们要导入数据以及输出结果。、
细分好工作后,我们先决定实现数据可视化。考虑到和Web端适配,我们决定使用WebGL开发,一开始使用原生的WebGL的开发是痛苦的。WebGL和OpenGL是类似的,而渲染管线又是流水线的工作,必须按照流程。于是我们寻找到更高效的开发工具three.js简化开发的流程。我们建立了一个建立的html页面,并成功载入三维CBCT数据,并渲染出来。
这是载入stl文件函数
这是我们需要加载的外部文件
我们需要创建场景、设置光源、设置相机、创建渲染器、执行渲染
我们开始给3D模型添加动画效果
一是添加旋转,让鼠标和场景完成交互。在网上搜索资料,有封装好的API可以调用,类似于JQuery,创建控件对象,然后监听鼠标、键盘事件,将render()函数做为参数。这样可以做出“相加跟随的效果”。
var controls = new THREE.OrbitControls(camera, renderer.domElement);//创建控件对象
controls.addEventListener('change', render);//监听鼠标、键盘事件
效果如下:
二是加入光照跟随的效果,起初环境光与点光源都是固定的,所以我们从不同的视角观测,模型的亮暗部分都是固定的。所以有的角度直面的是暗的部分,观测不清楚。我们定义好了render()函数,用来专门渲染物体。在这个render()函数中,我们先获取了相机的位置,然后新建一个点光源,把相机位置传入。这样,无论我们在何处观测,都有点光源会照在正对相机的表面上。极大地方便了用户观测三维模型。
function render() {
var vector = camera.position.clone();
//console.log(vector.x);
point.position.set(vector.x, vector.y, vector.z); //点光源位置
renderer.render(scene, camera);//执行渲染操作
//console.log(vector.x);
}
问题:
我们在添加监控对象的时候遇到了一个麻烦:即加入监听事件的对象后,程序保持着监听状态,但是刚点开网页的时候,却无法渲染出物体。只有通过移动鼠标,运动相机后才能渲染出。我们判断,是因为渲染的逻辑顺序的问题,“监听”的机制我们其实并不了解。但是我们可以在载入STL文件后,立即执行渲染,这样可以保证刚打开时,就能有模型显示。
为了进一步增加交互,提高用户友好性。我们尝试增加一些按钮。首先增加“Reset”按钮,即还原相机位置,让屏幕显示出从模型的正面观测。
<button type="button" onclick="reset()">重置方向</button>
我们按照html语法写了一个button,添加了onclick,一旦点击此按钮,便会执行“reset()”函数。
function reset() {
var v = camera.position.clone();
camera.position.set(0, 0, 200);
camera.lookAt(scene.position);
scene.position.set(0,0,0);
//var controls = new THREE.OrbitControls(camera,renderer.domElement);//创建控件对象
//controls.addEventListener('change', render);//监听鼠标、键盘事件
render();
}
Reset()函数是先把相机的位置归回最初的(0,0,200),把保存模型的场景位置归回(0,0,0),再把相机的目标点放到“场景”上,最后渲染出。就能实现“reset”。
同样的,添加了坐标轴的显示
坐标轴显示/消失
由于坐标轴显示与否是一个状态值,所以添加了简单的逻辑判断。
function axis() {
if (a == 0) {
//axesHelper.position.set(0,100,0);
scene.add(axesHelper);
a = 1;
//console.log(a);
}
else if (a == 1) {
scene.remove(axesHelper);
a = 0;
//console.log(a);
}
render();
}
如下图所示
为了保证画面的美观,看起来更灵活。我们加入了“自动旋转”,即当浏览器开启时,其实显示的模型就已经开始缓慢运动。这样看起来使得场景更具“高级感”。代码如下;
<button type="button" onclick="spin()">自动旋转</button>
<button type="button" onclick="speedup()">旋转加速</button>
<button type="button" onclick="speedown()">旋转减速</button>
function speedup() {
speed = speed + 0.002;
}
function speedown() {
if (speed - 0.002 >= 0.001) {
speed = speed - 0.002;
} else {
speed = 0.001;
}
}
function spinrender() {
mesh1.rotateY(speed);//每次绕y轴旋转0.01弧度
mesh2.rotateY(speed);
// camera.rotateY(speed);
angle = angle + speed;
render();
//renderer.render(scene,camera);//执行渲染操作
}
function spin() {
if (d == 0) {
myITV = setInterval("spinrender()", 20);
d = 1;
}
else if (d = 1) {
clearInterval(myITV);
d = 0;
mesh1.rotateY(-angle);//每次绕y轴旋转0.01弧度
mesh2.rotateY(-angle);
angle = 0;
render();
}
}
实验过程
我们考虑给模型增加不同的材质效果,首先对它的颜色改变。并且我们需要一个成型的GUI,在控件面板完成这些操作。我查阅资料,发现three.js已经有封装好的GUI给我们使用,我们可以轻松地调用这些方法实现颜色改变、模型大小等众多功能。但问题是调用的变量究竟是哪一个。Three.js的对象实在太多了,由于之前的工作已经在主js文件里填充了太多。我详细且耐心地查阅了three.js的流程。大致如下
将对象关系分析明确,就可以判断出GUI的API中,color是material的属性。
gui.addColor(params, "color").onChange(e => { //点击颜色面板,e为返回的10进制颜色
pointsMaterial.color.set(e);
});
所以我们修改之前的代码,把load函数里的材质提出到主js里。我们完成了对model的颜色修改(调色盘)。
但有一个困境难住了我:修改颜色后,无法实现立即渲染。其实归根结底是代码逻辑的不通顺以及three.js实现过程的不清晰。但是在修改颜色之后,添加了render()函数,立即渲染的问题却奇迹般的解决了。
gui.addColor(params, 'color')
.onChange(function () {
material.color.set(params.color);
render();
});
完成的效果:
思考
如此得出的问题:mesh早在加载模型时便已经绑定了material和geometry。Gui.AddColor代码逻辑上在mesh载入scene之后。改变material却引起scene的变化。那么有如下两个可能:
1.onChange()的侦听重新运行整个程序渲染出新的frame
2.Mesh即使在赋值后也是一直依赖于Material和geometry的。
完成的任务:
1.实现基于GUI的mesh的透明度修改。
2.上下颚的出现/隐藏
3.坐标轴的隐现
4.自动旋转
5.旋转速度调控
实现过程:
在添加完修改颜色模块后,增加其它功能模块就变得容易起来。只需要在设定参数的时候,增加“opacity”,并在gui中增加对material的opacity的修改。需要设置成滑块来改变。并设置步长为0.01。
up.add(params, "opacity", 0.3, 1.0).onChange(e => {
material1.opacity = e;
render();
}).step(0.01);
此外,增加了隐藏/出现的按钮。
up.add(params, 'visible').onChange(e => {
material1.visible = e;
render();
})
坐标轴的出现/隐藏
gui2.add(params2, "axis").onChange(e => {
if (e) {
scene.add(axesHelper);
}
else {
scene.remove(axesHelper);
}
render();
});
这里面需要注意的,我们修改的对象是scene。
旋转与速度设置。
gui2.add(params2, "spin").onChange(e => {
if (e) {
my = setInterval(function () {
mesh1.rotateY(speed);//每次绕y轴旋转0.01弧度
//mesh2.rotateY(speed);
// camera.rotateY(speed);
angle = angle + speed;
render();
}, 20);
}
else {
clearInterval(my);
}
});
gui2.add(params2, "speed", { slow: 0.001, medium: 0.005, fast: 0.01 }).onChange(function (value) { speed = value;
});
按时间间隔循环执行函数,针对的对象是mesh。我们也加入下拉菜单改变速率。
待解决:
GUI控件在窗口中的布局,以及隐藏、命名。
完成的任务:CBCT图像的显示
实验过程:
我查询了多种方法在代码中实现dicom类型数据的显示。首先由于我们基本javascript的three.js开发,便搜索three.js的工具包。发现虽然js有这种包,但是加载起来很是复杂他将会涉及到XAMPP等一些了解不太清的工具。而且占内存达160M,所以我尝试使用python来实现。
接下来有两条路在我的面前,一是直接尝试使用python开发web,但是由于先前都使用js开发,贸然切换成python很不利于工程衔接,而且有一大部分知识待重新学习。所以我选择另一条路,使用python的pydicom模块获取到图片的像素信息,保存在list中,然后把数据传给js文件再显示图像。幸运的,如今使用pydicom成功显示CBCT图像。
pix = []
#用来获取文档中所有文件名
Path = os.listdir("./lvsilin CBCT")
#用来获取所有文件的像素值,pix的类型是2维的
for i in Path:
filePath = "./lvsilin CBCT/"+i
print(filePath)
ds = pydicom.read_file(filePath)
pix.append(ds.pixel_array)
另外,我也成功导入了ply格式的文件,它与stl文件几乎相同,而且都是完整的牙。此处贴出导入的代码:
function plyloader(path) {
var plyloader = new THREE.PLYLoader();
plyloader.load(path, function (geometry) {
//更新顶点的法向量
geometry.computeVertexNormals();
mesh1 = new THREE.Mesh(geometry, material1);
mesh1.rotation.x = -Math.PI * 0.5;
scene.add(mesh1);
});
//render();
}
完成的任务:CBCT图像(dicom格式)的序列解析并显示,实现逐序列显示的效果。
先前基于自己手写的代码,已经完成了单帧dicom的显示,但是有如下问题:
1.代码基于python实现的,与js的通信受限。
2.可以通过逐帧保存成png/jpg来完成序列显示的任务,但是吃内存,耗时久,只能先保存转换到图片再直接提取。
3.从dicom到jpg难以三维重构
我为了解决如下的问题,查找了很多文档。其核心问题就是在于python与js的通信问题,其实再深一层,是无法找到合适解析dicom序列的api的问题。
有不少开源的代码解决单帧、多帧的,事实上,使用js解决问题的大多是基于conerstone框架迭代成新的框架。我寻找到了合适的框架sliceDrop,研究里面的方法,成功读出了多帧dicom并进行三维重构。
三维重构:
说起来内容不多,但是这是我利用网络工具解决问题的重要一课,利用工具,简化开发的流程。有针对性地从工程提炼问题,并检索。
待解决的问题:2d图像的效果并不尽如人意,3d的立方体noise和透明度参数并不合适,gamma、对比度的功能还没有实现。
完成的任务:三维口扫模型(含label)的点云形态可视化
从机器学习跑出分类数据后,out出来的是一个拥有N个点坐标的txt文件,以及对应的label,每个点对应一个label。实际上txt里每行有6个数据,所以一开始我花了不少时间解析txt点集并渲染,最终渲染的效果却不如意,比如渲染出一半牙齿,另有一团点集中在world space的原点。于是耗时良久后,最终答案是:前三个点是坐标,后三个点是法线。于是我们只需要解析前三个点就可以。
此外,我把不同顶点数据按照label已经存进了数组对象里。
代码如下:
var file = "result0.txt";
var rawFile = new XMLHttpRequest();
rawFile.open("GET", file, false);
rawFile.send(null);
var txt = rawFile.responseText;
var xD = txt.split(/\n| /);
xD.pop();
var Lab = "result.txt";
var rawFile2 = new XMLHttpRequest();
rawFile2.open("GET", Lab, false);
rawFile2.send(null);
var label = rawFile2.responseText;
label = label.split('\n');
label.pop();
console.log(xD.length);
//类别数
var num_label = 16;
//建立一个长度为num_label的数组,用来保存顶点数据
var doc = new Array(num_label);
//index是它的label
for (var i = 0; i < num_label; i++) {
doc[i] = new Array();
}
//数据导入
for (var i = 0; i < label.length; i++) {
doc[Number(label[i])].push(Number(xD[6 * i]));
doc[Number(label[i])].push(Number(xD[6 * i + 1]));
doc[Number(label[i])].push(Number(xD[6 * i + 2]));
}
当然会碰到一些数据处理的问题,使用的一些技巧就不在这里展开。
颜色的处理:一个label需要对应一个颜色。结合图形学与three.js的知识,我在绑定material的时候,改变顶点的颜色。给label做一个颜色映射是一个挺有趣的数学问题,当然我这里简单地解决了。
for (var j = 0; j < 3; j++) {
temp[j] = Math.round(Math.random() * 80 + 20) + i;
temp[j] = 128 + (255 - 128) / (116 - 20) * (temp[j] - 20);
temp[j] = Math.floor(temp[j]);
// console.log([temp[j]]);
}
还有一个值得一提的问题,material里的颜色使用的是字符串/三个16进制数连起来的数字。我用之前得到的从label映射的数字再转成16进制,练成字符串加上“#”成功渲染出不同颜色的点。
color: "#" + (temp[0]).toString(16) + (temp[1]).toString(16) + (temp[2]).toString(16)
效果如下:
待解决的问题:从点云(points cloud/points set)到面片(mesh/surface)的三维重建。
完成的任务:从点云(points)数据到面片(mesh)数据
进行这样的三维重构,我们真实在做的工作,其实是实现一个浏览器或者是可执行程序读取和解析model的过程。我们需要以下数据:点,在Open GL称之为顶点(vertex);索引,Open GL里的IBO存放的数据,按照pipline绘制的流程是需要这样的顶点顺序用以确定如何绘制三角形的;法线,normal,用来计算模型的材质(接受的光照参数值)。
使用的文件是.off文件,利用js内置的方法与函数便可以轻松把数据读取到自定义变量内。
自己写的读取顶点函数和读取label函数
function Vloader(path) {
var file = path;
var rawFile = new XMLHttpRequest();
rawFile.open("GET", file, false);
rawFile.send(null);
var txt = rawFile.responseText;
var xD = txt.split(/\n| /);//var xD = txt.split(/\n| /);
xD.pop();
var vertex = xD;
//数据导入
for (var i = 0; i < vertex.length; i++) {
vertex[i] = Number(vertex[i]);
}
return vertex;
}
function Iloader(path) {
//标签读入
var Lab = path;
//var Lab = "LowerJaw_vClassLabels.txt";
var rawFile2 = new XMLHttpRequest();
rawFile2.open("GET", Lab, false);
rawFile2.send(null);
var temp_1 = rawFile2.responseText;
var label = temp_1.split('\n');
for (var i = 0; i < label.length; i++) {
label[i] = Number(label[i]);
}
//label.splice(0, 1);
label.pop();
return label;
在geometry读取顶点后,需要读取索引信息,一个装有顶点顺序的txt。
geometry.index = new THREE.BufferAttribute(EBO, 1);
剩下的只需要更改材质为mesh材质,geometry和材质绑定的也是新的mesh即可。
material = new THREE.MeshBasicMaterial()
var mesh = new THREE.Mesh(geometry, material);
另外一个需要提到的,我们同样需要给geometry顶点着色。我需要一个文件装载所有顶点颜色数据,所以用了一些方法成功根据不同的label传入不同的颜色。颜色格式为(r,g,b)三个点为一组。之后,只用在材质里开启:
vertexColors: THREE.VertexColors
就能渲染出mesh了!
但由于label的问题,没有与顶点对应,所以最终渲染出的效果并不是成功分类的结果。
关于模型的材质,现在看起来极其乏味,只有单纯的颜色填充,并没有漫反射、镜面反射赋予的变化。在尝试了多次更改材质为MeshLambertMaterial和MeshPhongMaterial,他们却都无法正常渲染出来。
当然除了上面使用的off文件,我们还拥有txt的数据文件。txt内包含了顶点数据与法线数据,但却没有索引数据,所以我不得不投入三维重构的工作。即不依靠索引尽可能渲染出好的面片。有一篇非常优秀的论文探讨了这个问题:《Poisson Surface Reconstruction》
在搜索算法的时候,大多数都是依靠C++实现,并没有合适的js函数调用。所以实现这一算法还需要接下来进一步研究。
图片下次补上来.