事后谈:未完成的本科毕设
随着2023年6月1日下午答辩的结束,本科生涯也落下了帷幕。故此写一些文字,来复盘之前的思路,以及为可能存在的后续研究做准备。
基本信息
我得到的选题是关于奶山羊的名为「角根」(angular root)的某遗传性状的研究。但是无论是网络上还是图书馆里都没有找到系统且充足的信息,以至于连这个英文名字都来自于李老师的解答。不过按照与老师们的只言片语以及对手头上山羊的图片如王阳明盯竹子一般的观察可以得到一些信息:
- 角根是在无角山羊额头处的骨质突起,单就目测而不考虑其他因素,突起的形态存在个体差异
- 突起最高处通常位于眼睛上方
- 拍摄的图片问题不小,而且由于取样难度的问题,也很难拍好
思路的确定
当时看到老师发的关于选题的消息的时候,我真的不知道应该怎么做。但是大概想了下涉及到的东西不少,所以最早并没有开始正式的研究(?)。主要是提前的准备还有长达好几个月的学习(我可以称之为学习吧)。
我看的出来嘛?不知道
我其实并没有参与采样,这个活是师兄干的。所以到我手头上的数据,只有一个 Excel 表格还有几百兆的图片。我简单处理了下把表格变成了 CSV 的格式,然后写了个小脚本把图片按照耳标分到了不同的文件夹。
简单找了几张图看了下,就图片本身而言,大概是这样的:
- 清晰的只有耳标但是没有我需要的部位
- 角度不一,差别特别大
- 光照条件也不一样
其实令我苦恼的不是单纯的角度问题,而是不同图片的拍摄条件都是不一样的,不过这个苦恼是后话了。我当时下意识的想法就是分步来操作:通过某些算法得到的参数作为中间值,通过不同模型的处理得到最终的结果(假设结果存在)。
我有一次问老师,他说给我这个选题的来源是经常在羊场和🐐打交道的某老师的经验——通过这种「角根」(这个词会在下文出现多次,恕不再打引号)的差异,可以确定无角山羊的基因型,但是实际测了几组发现不一样,也因此想希望我来整篇研究,确定这就是没什么关系的。
好嘞,原来这玩意的意义这么深远啊,那更得好好整了。
在此之后的几天时间,我得到了一点最开始的思路:按照三维重建 => 特征提取与降维 => 关联分析来进行。
数据处理?三维重建!
要快速的了解一个领域,首先要搞明白他们(这个领域的从业者以及研究人员)到底在说些什么。就比方说,一个不了解鬼畜的人,就不知道金坷垃、一块不大不小的木板、AIPC、超威蓝猫、Boy♂next♂door以及提乾涉经等被观众津津乐道的词语是什么意思。当然,对一个领域的认知并不是与生俱来的,它是需要一段时间的摸索和练习才能掌握的。
对于一个新的领域的了解也是这样的。首先需要明确下任务,再根据已有信息来整合下以得出现有的解决方案。
我们的任务就是从几张有限、缺乏相关位置的图片获得能够体现山羊头顶三位特征的什么东西。这一步怎么说都确实不算很简单。一方面是抓羊的难度,另一方面是没有合适的算法。
然后,通过得到的结果分析这种特征与特定基因之间的关联。
相比于前面那个倒还好做。
视角与相机位参
立体特征的抓取
做不了
这部分我直接 po 论文里的内容吧:
在项目早期技术选型时,笔者试图选择利用三维重建技术来获得羊的颅顶部位的立体特征,再将产生的特征张量进行下一步的分类或是关联分析的多步思路。因为图片的形式可以看成是带有干扰的三视图,在理想情况下,附带着相机位参的多视图是很多三维重建算法理想的输入形式。也因此查阅了大量三维重建领域的文献,三维重建属于计算机视觉中难度比较大的范畴。最后发现无论采用任何算法,对于目前采集所得的数据的情况而言,拍摄清晰有效且信噪比高利于重建的图像异常困难,原因如下:
其一是获取高质量数据本身的难度。拍摄时需要对羊只进行保定操作,意味着画面内可能融入很多人类肢体的内容,这增加了重建的难度,同时不同角度下柔软的羊耳朵也是干扰项之一。另外,山羊生性胆小,如果在不进行保定的贸然拍摄将不会获得任何有效信息,而且可能会造成羊群的应激,对生产性能有潜在危害。
其二是数据集样本量以及算力的制约。无论是传统方法还是最近正火热的基于极度学习的重建方法,都需要大量样本的训练以及极大的 GPU 时间以及算力来完成训练。就以 FvOR (Few-View Object Reconstruction, 稀疏视角物体重建)算法为例[1],其依靠姿态初始化模块(pose initialization module)和形状重建模块(shape reconstruction module),通过一系列的迭代步骤可在仅有少数图像且姿态不准确的情况下预测出精确的三维模型。但是其基于 ShapeNet 数据集,如果要能够完成根据部分照片重建出羊的颅顶的三模模型的任务,需要设计特定的新数据集来继续训练。并且严格来说,针对羊的颅顶的重建属于表面模型构建,该算法并不适用,因为并不需要完整的三维体。另外,柔软的羊耳、为了保定而控制羊的人的肢体以及拍摄时画面光影的变化均会对模型的效果带来极大的干扰。
基于以上原因,放弃通过重建出精确的三维模型来获得立体特征的思路。
怎么实现?
三维特征的提取
复杂管线多步流程
最开始我是真的打算开发出一个算法然后发到顶会上的。
轮廓线
这个词其实是我问 ChatGPT 的。
特征点转化为特征值
当时我就考虑了一点:怎么抵消因为拍摄的角度问题带来的畸变。
……
相关代码解释
其实如果会 Python 的话(这里的「会」的意思是比
Hello world
稍微了解的再深入点),应该都能看懂,但就是有点繁琐。
首先是引入一些类或是类型:
# 如果是 Py 3.11 及以上的话就可以直接使用 StrEnum
from enum import Enum
from math import sqrt
from typing import List, Tuple, Dict, Any因为每个特征点的角色都不一样,所以我当时使用 Enum
来标记这种差异(后面发现这个其实没用):
class PositionEnum(str, Enum):
# 整个底部
BUTTOM = "b"
# 两边的底部
SIDE_BUTTOM = "sb"
# 两边的顶
SIDE_TOP = "st"点这个元素,有横坐标和纵坐标,也就是
[x, y],故有(这个源代码没有,我加的):
PointType = List[float]因此对于标注对象的几个属性就确定了:
class MiddleAnnotation:
# 没有解析过的原始数据
raw: List[PointType]
side_top: Tuple[List[PointType], PositionEnum]
side_buttom: Tuple[List[PointType], PositionEnum]
buttom: Tuple[PointType, PositionEnum]
...按照原始思路,我们需要计算中点、平行线相关点的坐标,所以需要实现一些工具方法:
class MiddleAnnotation:
...
@staticmethod
def _calulate_distance(
p1: PointType,
p2: PointType
) -> float:
"""计算两点间距离"""
...
@staticmethod
def generate_middle_point(
p1: PointType,
p2: PointType
) -> PointType:
"""返回两点所连线段的中点"""
...
@staticmethod
def get_merge_point(
p1: PointType,
p2: PointType,
p1_ref: PointType,
p2_ref: PointType,
) -> PointType:
"""
返回过(p1, p2)以及(p1_ref, p2_ref)两条之间的交点
"""
...
@staticmethod
def generate_paralle_line_point(
p1: PointType,
p2: PointType,
point: PointType,
p1_ref: PointType,
p2_ref: PointType,
) -> PointType:
"""
返回过某点交某直线的平行于另一条直线的点
"""
# 这函数写的什么玩意儿,看不懂,等过两天再看
...之后,再就是使用这些工具方法来完成相对复杂的操作:
class MiddleAnnotation:
...
# 实际的操作有两个函数
# 一个是 self._parse() ,负责将点按照相对的距离进行分类
# 另一个是 self.exeute() ,负责计算特征值
def _parse(self) -> None:
# 声明点一定存在,要不然后面不知道会出什么 bug
assert self.raw is not None
# 再把第一个点在最后复制一次,方便后面的计算
raw_ = [self.raw + [self.raw[0]]].copy()[0]
# 容纳相邻点之间的距离
location_list = []
# 计算相邻点之间的距离
# 我们假定标注图形是一个多边形
# (没人会闲得乱标吧... 没人吧...)
for idx, pos in enumerate(raw_[1:]):
location_list.append(self._calulate_distance(raw_[idx], pos))
location_comp_list = location_list.copy()
# 两侧角连线(最长的那个)
location_between_side_top = max(location_comp_list)
location_comp_list.remove(location_between_side_top)
side_top_idx = location_list.index(location_between_side_top)
if side_top_idx == len(self.raw) - 1:
side_top_idx = (0, len(self.raw) - 1)
else:
side_top_idx = (side_top_idx, side_top_idx + 1)
side_buttom_idx = list(range(len(self.raw)))
side_buttom_idx.remove(side_top_idx[0])
side_buttom_idx.remove(side_top_idx[1])
side_buttom_idx = tuple(side_buttom_idx)
self.side_top = (
[self.raw[min(side_top_idx)], self.raw[max(side_top_idx)]],
PositionEnum.SIDE_TOP,
)
self.buttom = (self.raw[side_buttom_idx[1]], PositionEnum.BUTTOM)
self.side_buttom = (
[self.raw[min(side_buttom_idx)], self.raw[max(side_buttom_idx)]],
PositionEnum.SIDE_BUTTOM,
)
def execute(self) -> Dict[str, Tuple[float, float]]:
# 两角连线的中点
top_middle_point = self.generate_middle_point(
self.side_top[0][0], self.side_top[0][1]
)
# Smaller side.
paraller_to_middle_smaller = self.generate_paralle_line_point(
self.side_top[0][0],
self.side_top[0][1],
self.side_buttom[0][0],
self.buttom[0],
top_middle_point,
)
paraller_to_top_smaller = self.generate_paralle_line_point(
top_middle_point,
self.buttom[0],
self.side_buttom[0][0],
self.side_top[0][0],
self.side_top[0][1],
)
# Larger side.
paraller_to_middle_larger = self.generate_paralle_line_point(
self.side_top[0][0],
self.side_top[0][1],
self.side_buttom[0][1],
self.buttom[0],
top_middle_point,
)
paraller_to_top_larger = self.generate_paralle_line_point(
top_middle_point,
self.buttom[0],
self.side_buttom[0][1],
self.side_top[0][0],
self.side_top[0][1],
)
## 计算
## length ratio.
lg_l = self._calulate_distance(
paraller_to_middle_smaller, self.buttom[0]
) / self._calulate_distance(paraller_to_top_smaller, self.side_buttom[0][0])
lg_s = self._calulate_distance(
paraller_to_middle_larger, self.buttom[0]
) / self._calulate_distance(paraller_to_top_larger, self.side_buttom[0][1])
## area ratio.
# 先定义一个权重
# 这个权重是一个 Magic number
# 分别是水平与垂直方向的
# 其实没什么卵用,最后乘回去了
hor_wgt = 0.9
vtc_wgt = 0.3
ar_s = (
hor_wgt
* self._calulate_distance(
paraller_to_middle_smaller, self.side_buttom[0][0]
)
* vtc_wgt
* self._calulate_distance(paraller_to_middle_smaller, self.buttom[0])
) / (
hor_wgt
* self._calulate_distance(top_middle_point, self.side_top[0][0])
* vtc_wgt
* self._calulate_distance(top_middle_point, self.buttom[0])
)
ar_l = (
hor_wgt
* self._calulate_distance(paraller_to_middle_larger, self.side_buttom[0][1])
* vtc_wgt
* self._calulate_distance(paraller_to_middle_larger, self.buttom[0])
) / (
hor_wgt
* self._calulate_distance(top_middle_point, self.side_top[0][1])
* vtc_wgt
* self._calulate_distance(top_middle_point, self.buttom[0])
)
# 返回特征值
return {"LengthRatio": (lg_s, lg_l), "AreaRatio": (ar_s, ar_l)}最后,整合一下前述操作,只需要简单的:
res = []
for points in annotation_list:
x = MiddleAnnotation(points)
res.append(x.execute())
即可。
当时叫 mdj 来帮我标注,然后我考虑后面的算法。
其实这些代码看着复杂,但也就是高中数学的水平。
数据靠谱不?
因为我们并没有在该领域的先验知识,所以对于得到的参数进行某种验证。
后面怎么用
神经网络还是概率图网络?
{{ 考虑参数数目巴拉巴拉 }}
现学贝叶斯
{{ 关于贝叶斯理论的什么什么 }}
结论算结论吗?
结论就是概率分布。
| 基因型 | 概率 |
|---|---|
| var/var | 0 |
| var/wt | ?? |
| wt/wt | ?? |
以上是从给定性状入手,得到的基因型概率分布的示例。
实际上,我们也可以从其他的角度来对概率图模型输出的概率分布进行描述。然而,从项目的入手点,也就是【试图】鉴别不同的基因型的可能性入手,还是会得到性状到基因型更好的结果。