暑期项目实训:基于webgl(three.js)的牙科数据可视化展示

第一天:

本组的选题是“牙科数据的分割与分类及可视化展示平台”。工作主要划分成三块:前端平台页面搭建、算法与数据可视化、后端及数据库。我和另一个同学一起负责算法与数据可视化。
根据学长给的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函数调用。所以实现这一算法还需要接下来进一步研究。

图片下次补上来.

你可能感兴趣的:(暑期项目实训,webgl,three.js,可视化)