这是一个很有意思的app,你可以在屏幕上跟着前人的书法作品,用手指在其上临摹。app会根据你的临摹是否贴近原来的字给出准确率和溢出率,并在最后给予评分。先放源码链接
app
打开app后能够看到的是MainActivity,上面是一个RecyclerView展示了一些文言文语句,用户可以选择任意语句进入到CalligraphyActivity中进行临摹。用户并不需要临摹一整句话,而是一句话中的某几个字而已,这些字呈现出了浅棕色。这么做的理由是我希望用户在使用这个app时是轻松的,没有上学期间抄书的感觉。
进入CalligraphyActivity后,中间是一个绘画框,这是一个自定义View:CharacterDrawView。在CharacterDrawView内部组合了两个bitmap对象:一个负责展示原来的书法字,另一个则负责展示用户绘画的结果。当启动Activity后,将书法字传递给该View。除此之外,还需要传递准确率的监听接口。每当准确率以及溢出率更新时,就应该更新界面数据。
/**
* 在CharacterDrawView上展示文字
* 这是该Activity中配置CharacterDrawView的唯一入口
*/
private void loadCharacter() {
binding.calligraphyCurrCharacterDrawView.setEvaluateListener((filledRate, overFilledRate) -> {
updateEvaluateRate(filledRate, overFilledRate);
currFilledRate = filledRate;
currOverFilledRate = overFilledRate;
});
binding.calligraphyCurrCharacterDrawView.post(() -> {
try {
binding.calligraphyCurrCharacterDrawView.setBitMapAsBackground(
CalligraphyUtils.getCalligraphyBitmap(currCalligraphyText.name(), characterIdx, CalligraphyActivity.this)
);
} catch (IOException e) {
Toast.makeText(CalligraphyActivity.this, "找不到图片", Toast.LENGTH_SHORT).show();
}
});
}
来看CharacterDrawView,首先是设置图片的逻辑。书法字图片的大小比不上这个view的大小,因此首先需要对图片等比放大,用到的是Bitmap.createScaledBitmap()
。用户的画板大小和View相同,所以直接调用Bitmap.createBitmap()
就好。其次我会希望这个字能够恰好在view的靠中间位置展示,所以计算了characterBitmapOffsetX/Y,这个参数会在稍后的onDraw()阶段用到。最后,调用invalidate()
方法触发view的绘制动作。要注意的是,这个方法尽管是在onCreate()
中调用的,但是最后被调用的时机应该在这个view得到绘制以后,否则得到的宽高为0。所以应该使用view.post
来调用这个方法。
其次,用户画板在待临摹字之上,为了能够在绘画过程中仍然能够看到原来的字长什么样,所以用的是半透明的笔。
/**
* 将图片等比放大,
* 并为临摹准备好画板
*
* @param src 一个待临摹的书法字
*/
public void setBitMapAsBackground(Bitmap src) {
int viewHeight = getHeight(), viewWidth = getWidth();
int height = src.getHeight(), width = src.getWidth();
paddingLeft = getPaddingLeft();
paddingTop = getPaddingTop();
float heightRate = (float) (viewHeight - 2 * paddingTop) / height;
float widthRate = (float) (viewWidth - 2 * paddingLeft) / width;
float rate = Math.min(heightRate, widthRate);
int finalWidth = (int) rate * width, finalHeight = (int) rate * height;
characterBitmapOffsetX = (viewWidth - finalWidth) >> 1;
characterBitmapOffsetY = (viewHeight - finalHeight) >> 1;
characterBitmap = Bitmap.createScaledBitmap(src, finalWidth, finalHeight, true);
drawBitmap = Bitmap.createBitmap(viewWidth, viewHeight, Bitmap.Config.ARGB_8888);
drawCanvas = new Canvas(drawBitmap);
drawCanvas.drawColor(Color.WHITE);
path = new Path();
initPaint();
invalidate();
}
接下来要重写该View的点击事件响应onTouchEvent()
,把用户的手指当作笔,手指的轨迹用path存储起来。这个方法参考了网上,当下笔时,使用path.moveTo()
指定起点,笔移动过程中,不断调用path.quadTo()
方法绘制曲线,松开笔时,将path绘制到bitmap中,然后重置该path,并评估准确率和溢出率。无论action是按下、滑动还是松开,最后都需要调用onDraw()方法将用户的实时绘制路径展示在画布上。因此直到path绘制到bitmap前,用户都能够在画板上看到path;但又由于path也是用半透明笔画的,所以绘制过程中和绘制后的笔迹在重叠部分看起来会有差距,目前我没有办法解决这个问题。
@Override
public boolean onTouchEvent(MotionEvent event) {
if (characterBitmap == null) return false;
//获取触摸事件发生的位置
float x = event.getX();
float y = event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
//将绘图的起始点移到(x,y)坐标点的位置
path.moveTo(x, y);
preX = x;
preY = y;
break;
case MotionEvent.ACTION_MOVE:
float dx = Math.abs(x - preX);
float dy = Math.abs(y - preY);
if (dx > 5 || dy > 5) {
//.quadTo贝塞尔曲线,实现平滑曲线(对比lineTo)
//x1,y1为控制点的坐标值,x2,y2为终点的坐标值
path.quadTo(preX, preY, (x + preX) / 2, (y + preY) / 2);
preX = x;
preY = y;
}
break;
case MotionEvent.ACTION_UP:
drawCanvas.drawPath(path, paint);//绘制路径
path.reset();
evaluatePaint();
break;
}
invalidate();
return true;//返回true,表明处理方法已经处理该事件
}
最后来看图片的评估,首先要将整个bitmap转化成数组的形式并将其二值化为只有0和1,然后再通过循环遍历的方式令每个像素之间进行比较,如果带临摹bitmap和用户绘制bitmap在图片的同一个位置的值都为1,那么这是准确的,如果前者值为0但后者值为1,则视为溢出。计算得到准确率和溢出率。因为担心这一步需要长时间的计算,所以新开了一条线程进行处理。
public void evaluatePaint() {
new Thread(() -> {
int paintCnt = 0, drawCnt = 0, fillCnt = 0, overFillCnt = 0;
byte[] drawArr = bitmapToByteArray(drawBitmap);
byte[] characterArr = bitmapToByteArray(characterBitmap);
int drawHeight = drawBitmap.getHeight(), drawWidth = drawBitmap.getWidth();
int characterHeight = characterBitmap.getHeight(), characterWidth = characterBitmap.getWidth();
for (int i = 0; i < drawArr.length / drawHeight; i++) {
for (int j = 0; j < drawArr.length / drawWidth; j++) {
int mappedIdx = i * drawHeight + j;
int mappedIdxCharacter = -1;
if (i >= characterBitmapOffsetY
&& j >= characterBitmapOffsetX
&& i - characterBitmapOffsetY < characterHeight
&& j - characterBitmapOffsetX < characterWidth) {
mappedIdxCharacter = (i - characterBitmapOffsetY) * characterWidth + (j - characterBitmapOffsetX);
if (characterArr[mappedIdxCharacter] == 0) paintCnt++;
if (characterArr[mappedIdxCharacter] == 0 && drawArr[mappedIdx] == 0)
fillCnt++;
if (characterArr[mappedIdxCharacter] != 0 && drawArr[mappedIdx] == 0)
overFillCnt++;
}
if (drawArr[mappedIdx] == 0 && mappedIdxCharacter == -1) overFillCnt++;
if (drawArr[mappedIdx] == 0) drawCnt++;
}
}
Log.d("bitmap", "paintCnt = " + paintCnt + ", drawCnt = " + drawCnt + ", fillCnt = " + fillCnt + ", overFillCnt = " + overFillCnt + ", drawBitmap = " + drawBitmap.getWidth() * drawBitmap.getHeight());
filledRate = (float) fillCnt / paintCnt;
overFilledRate = (float) overFillCnt / paintCnt;
handler.post(() -> evaluateListener.onEvaluated(filledRate, overFilledRate));
}).start();
}
bitmap转换为数组的函数也是参照网上的,先将图片的RGB三种信息转化为灰度信息,然后根据灰度信息值进行二值化处理,阈值为0,毕竟笔迹是黑色的。
byte[] bitmapToByteArray(Bitmap bitmap) {
int width = bitmap.getWidth();//原图像宽度
int height = bitmap.getHeight();//原图像高度
int color;//用来存储某个像素点的颜色值
int r, g, b, a;//红,绿,蓝,透明度
//创建空白图像,宽度等于原图宽度,高度等于原图高度,用ARGB_8888渲染,这个不用了解,这样写就行了
Bitmap bmp = Bitmap.createBitmap(width, height
, Bitmap.Config.ARGB_8888);
int[] oldPx = new int[width * height];//用来存储原图每个像素点的颜色信息
byte[] res = new byte[width * height];//用来处理处理之后的每个像素点的颜色信息
/**
* 第一个参数oldPix[]:用来接收(存储)bm这个图像中像素点颜色信息的数组
* 第二个参数offset:oldPix[]数组中第一个接收颜色信息的下标值
* 第三个参数width:在行之间跳过像素的条目数,必须大于等于图像每行的像素数
* 第四个参数x:从图像bm中读取的第一个像素的横坐标
* 第五个参数y:从图像bm中读取的第一个像素的纵坐标
* 第六个参数width:每行需要读取的像素个数
* 第七个参数height:需要读取的行总数
*/
bitmap.getPixels(oldPx, 0, width, 0, 0, width, height);//获取原图中的像素信息
for (int i = 0; i < width * height; i++) {//循环处理图像中每个像素点的颜色值
color = oldPx[i];//取得某个点的像素值
r = Color.red(color);//取得此像素点的r(红色)分量
g = Color.green(color);//取得此像素点的g(绿色)分量
b = Color.blue(color);//取得此像素点的b(蓝色分量)
a = Color.alpha(color);//取得此像素点的a通道值
//此公式将r,g,b运算获得灰度值,经验公式不需要理解
int gray = (int) ((float) r * 0.3 + (float) g * 0.59 + (float) b * 0.11);
if (gray > 0) {
res[i] = 1;
} else {
res[i] = 0;
}
}
return res;
}
回到CalligraphyActivity中,当用户绘制好了所有字以后,点击“大功告成”按钮,就会在一个Dialog中展示用户的绘制结果、最终成绩以及我想了好久的骚话,来给出评价。最终的绘制结果是将一整句话的所有字合并成一行,并将用户所绘制的字代替原作。看起来就像是我和远古的大书法家一起携手完成了一幅作品,应该说是蛮有成就感的吧。
/**
* 展示最终的bitmap以及评价
* @param resultBitmap 合并后的bitmap
*/
@SuppressLint("SetTextI18n")
public void setFinishDialog(Bitmap resultBitmap) {
sumFilledRate += currFilledRate;
sumOverFilledRate += currOverFilledRate;
int characterCnt = currCalligraphyText.getTarget().length;
float averFilledPercentage = sumFilledRate / characterCnt * 100, averOverFilledPercentage = sumOverFilledRate / characterCnt * 100;
AlertDialog.Builder dialog = new AlertDialog.Builder(this);
View view = LayoutInflater.from(this).inflate(R.layout.dialog_finish_work, null);
TextView title = view.findViewById(R.id.dialog_title);
TextView score = view.findViewById(R.id.dialog_score);
TextView message = view.findViewById(R.id.dialog_message);
ImageView image = view.findViewById(R.id.dialog_image);
float finalScore = averFilledPercentage * 1.2f - averOverFilledPercentage * 0.5f;
DoneComments comments;
if (finalScore > 80) comments = DoneComments.PERFECT;
else if (finalScore > 60) comments = DoneComments.GOOD;
else if (finalScore > 40) comments = DoneComments.OK;
else comments = DoneComments.BAD;
title.setText(comments.getTitle());
score.setText(finalScore + "分");
message.setText(comments.getMessage());
image.setImageBitmap(resultBitmap);
dialog.setView(view).setPositiveButton("确认", (dialog1, which) ->
CalligraphyActivity.this.finish()
).show();
}
图片的合并需要先获得合并后图像的宽和高,然后再利用canvas.drawBitmap()
把每个字绘制。在1.0的版本中,我没有指定绘制单独每个字图像的高度,所以文字最终会扭扭曲曲上下浮动;在1.1版本中,我把图像的字绘制在垂直方向的正中间,效果好多了;但在写这篇博客的时候我觉得,按照正常的阅读习惯,应该把文字底部保持在一条线上才对吧(苦笑)
/**
* 合并所有字为一个bitmap
*
* @return 合并结果
*/
private Bitmap concatCalligraphyBitmapSet() {
if (list.size() == 0) return null;
int resWidth = 0, maxHeight = 0;
for (int i = 0; i < list.size(); i++) {
Bitmap curr = list.get(i);
resWidth += curr.getWidth();
maxHeight = Math.max(curr.getHeight(), maxHeight);
}
Bitmap result = Bitmap.createBitmap(resWidth, maxHeight, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(result);
resWidth = 0;
for (int i = 0; i < list.size(); i++) {
Bitmap curr = list.get(i);
canvas.drawBitmap(curr, (float) resWidth, (maxHeight - curr.getHeight()) >> 1, null);
resWidth += curr.getWidth();
}
canvas.save();
canvas.restore();
return result;
}
这就是整个用户在app中的使用流程了,事实上这个项目还有别的能谈。app怎么能够知道当前应该展示哪一张书法字图像呢?先来看到这些图像存储的位置:位于与res文件夹同级的assets文件夹内,这个文件夹需要自己创建。之所以图像不是存储在res/drawable中一方面是因为图像的命名用了大写,添加之后IDE会给警告,另一方面是因为如果使用R类去指定图片似乎没办法直截了当地指出第i张图像。以下是打开图像的逻辑,这段代码位于CalligraphyUtils中,是一个工具类
public static Bitmap getCalligraphyBitmap(String id, int idx, Context context) throws IOException {
InputStream inputStream = context.getResources().getAssets().open(id+"_"+idx+".jpg");
Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
inputStream.close();
return bitmap;
}
每一句书法字都视为一个对象,使用了枚举类CalligraphyTexts声明。plainText成员是这句话没有标点符号的版本,需要这个成员的原因是古人的书法作品里面也没有标点;text就是在课本里有标点符号的版本了;target数组里的每一个index都是plainText中的第i个字,这些字就是用户需要绘制的字了。(应该弄一个随机数的,不然用户没尝试几天就厌了)
public enum CalligraphyTexts {
LanTing_KuaiRang("快然自足不知老之将至","快然自足,不知老之将至", new int[]{1,8}),
LanTing_TianLang("天朗气清惠风和畅","天朗气清,惠风和畅", new int[]{0,1,3}),
LanTing_Yangguan("仰观宇宙之大俯察品类之盛","仰观宇宙之大,俯察品类之盛", new int[]{5,7,11});
private final String plainText;
private final String text;
private final int[] target; // for plainText
CalligraphyTexts(String plainText, String text, int[] target){
this.plainText = plainText;
this.text = text;
this.target = target;
}
// ...
}
所以一句文言文大概10个字,就需要大概10张左右的图像,如果需要程序开发者手动裁切那可就太无趣了,既缺乏效率,也为之后拓展更多文言文带来了阻碍。因为很巧的是现在的我正在接收数字图像处理的熏陶,所以决定去实现一个将一张书法图像的文字分割的工具。事实上我的开发顺序是先制作了分割工具,然后才着手开发app的。
分割
文字分割在电脑完成,其函数利用java与openCV实现。使用openCV的原因是因为开发阶段,我不确定在app上对图像的处理(比如二值化)需要怎样的工具,是否能够不依赖openCV就可以完成程序逻辑;其次是因为当时的我对matlab一窍不通,不知道怎么使用循环或者写一个方法(现在知道了,毕竟实验课要用)。使用java搭配openCV的体验有点糟糕,因为openCV中提供的很多方法使用C++实现的,而且提供的javaDoc既不好找也不全面(我已经找不到了)。这是官方比较好的指南,虽然不是用java开发的,但是能够提供帮助,w3cSchool好像有它的中文版。我参考的另一篇文档是别人的博客,确实是保姆级的教程。
进入正题,我需要先找到一副书法作品,因为我只认得《兰亭集序》,所以就是它了。尽管网上有临摹的作品,背景很干净,没有杂质,这使得图像的预处理方便很多,但选择原作显然比选择临摹作品更显得有意义。以下是原作。
文字分割工具并不能够直接对整幅书法作品进行分割,需要先手动裁剪几列文字再处理,且要求列与列之间应该有一条垂直的缝隙,字与字之间也要有水平的缝隙,否则无法分割。文字分割的过程包括了图像预处理,图像按列分割,图像按字分割这3个步骤
/**
* 将一张竖向、从右往左的书法按照文字分割
*
* @param start 从第start个字开始截取,这是从右往左的第一列、由上往下开始的
* @param startIdx 分割后的文字的起始index
* @param id 语句id,在app中使用该id选择图片
* @param text 语句
* @param imageUrl 待切割图像源
* @param splitPercent 文字与文字间隔的比例,这是为了防止如“旦”字被分割为“日”和“一”。玄学参数
* @param threshold 二值化的阈值
* @param blurSize 滤波器模块大小
*/
void split(int start, int startIdx, String id ,String text, String imageUrl, int splitPercent, int threshold, int blurSize){
Mat image = getBinaryImage(Imgcodecs.imread(imageUrl), threshold, blurSize);
ArrayList<Mat> columnMats = splitColumns(image);
ArrayList<Mat> charcterMats = splitRows(columnMats, splitPercent);
System.out.println(charcterMats.size());
char[] textArr = text.toCharArray();
for (int i = 0; i < textArr.length; i++) {
Imgcodecs.imwrite("imgOut/"+id+"_"+(i+startIdx)+".jpg", charcterMats.get(start-1+i));
}
}
预处理的内容包括了图像的低通滤波(模糊处理),二值化,以及开闭运算。二值化是将整张彩色图像先转化为灰度图像,然后再转化为非黑即白的图像。一张非黑即白的图像对于计算机来说是很多个像素点的集合,其中黑色点的值为0,白色点的值为1。当得到一张二值图像后,就可以根据某一行或者某一列是否全为1作为判断这是否是缝隙的依据。而模糊处理是为了将图片的噪点尽可能地过滤掉;开闭运算是我一开始百度到去除噪点的方法,但是最终测试得到的效果存疑,似乎未必对图像清晰度带来改进。图像预处理部分之后在一次图像处理的课上被我作为小组分享的主题。
Mat getBinaryImage(Mat original, int threshold, int blurSize){
int height = original.rows(), width = original.cols();
Mat grayImage = new Mat(height, width, CvType.CV_8SC1);
Mat binaryImage = new Mat(height, width, CvType.CV_8SC1);
Imgproc.cvtColor(original, grayImage,Imgproc.COLOR_RGB2GRAY);
Imgproc.blur(grayImage, grayImage, new Size(blurSize,blurSize));
Imgproc.threshold(grayImage, binaryImage, threshold, 255, Imgproc.THRESH_BINARY);
// Imgproc.threshold(grayImage, binaryImage, 120, 255, Imgproc.THRESH_BINARY | Imgproc.THRESH_OTSU);
Mat kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT,new Size(2,2));
Imgproc.morphologyEx(binaryImage,binaryImage,Imgproc.MORPH_OPEN,kernel);
Imgproc.morphologyEx(binaryImage,binaryImage,Imgproc.MORPH_CLOSE,kernel);
return binaryImage;
}
列的分割是根据缝隙分割的;字的分割如法炮制,但是却发现了有些字的内部就存在这缝隙,比如“快然自足”的“然”字,上半部分和下半部分的四个点是能够找到一条水平的缝隙的,最终会将这个字分成两部分。所以想了一个办法就是根据字与间隙的比例来判断这是字与字之间的间隙还是一个字内部的间隙。如果上一个字乘以该比例的高度大于当前间隙的高度,则认为这是一个字的内部间隙,反之是判断为字与字之间。这个判断方法显然不够准确,因为书法家有时候会把字的间隙写得时大时小;有时候也会因为一些字形的缘故,把字之间写得先对紧凑也没关系。所以这就有了玄学参数splitPercent
的来源,这个参数指定的就是上述的比例,如果当前图像源中的书法中没有一个字会出现内部可分割的情况,那么这个比例应该尽量地大。与之搭配的参数startIdx
就是在间隙时大时小的情况下,将同一张图片两次进行分割后,让分割后的图像id保持顺序用的。
ArrayList<Mat> splitRows(ArrayList<Mat> columnMats, int splitPercent){
int textColumnSize = columnMats.size();
ArrayList<Mat> characterMats = new ArrayList<>();
for (int i = 1; i <= textColumnSize; i++) {
Mat curr = columnMats.get(textColumnSize-i);
byte[][] imageByte = mat2ByteArray(curr);
int row = imageByte.length, column = imageByte[0].length;
int hasPaintIdx = 0, lastStart = 0, lastTextHeight = 0;
boolean imageStart = true;
for (int j = 0; j < row; j++) {
boolean noPaintInRow = true;
for (int k = 0; k < column; k++) {
if(imageByte[j][k]==0){
noPaintInRow = false;
break;
}
}
// printByteArrayInTerminal(imageByte[j]);
if(!noPaintInRow || j==row-1){
if(!imageStart && hasPaintIdx!=j-1){
if(j!=row-1 && j-hasPaintIdx<lastTextHeight/splitPercent){
// 防止部分汉字因其笔划不相连而导致被分离
// 当前策略:若当前间隙小于上一个字的高度除以文字间隔比例,则认为这是字的一部分
hasPaintIdx = j;
continue;
}
int mid = (hasPaintIdx+j)>>1;
characterMats.add(curr.submat(lastStart, mid, 0, column-1));
lastTextHeight = mid - lastStart;
lastStart = mid+1;
}else imageStart = false;
hasPaintIdx = j;
}
}
}
return characterMats;
}
以下是我对各张书法字的文字分割参数,可以看出有几句话是从中间断开了的,尽管“按照比例判断是否是字与字之间的间隙”这种方法很糟糕,但我也没有找到更好的方法了。
因为我不知道怎么把文字分割函数的代码我的安卓项目放在一起发布,所以我把整个函数注释掉然后贴在我的安卓项目里面了。
ts.split(8, 0, "LanTing_KuaiRang", "快然自足不知老之将至", "img/LaoZhiJiangZhi.jpg", 12,125,5);
ts.split(4, 0, "LanTing_TianLang", "天朗气清惠风和畅", "img/TianLangQiQing.jpg",50,125,5);
ts.split(12, 0, "LanTing_Yangguan", "仰", "img/TianLangQiQing.jpg",50,125,5);
ts.split(1, 1, "LanTing_Yangguan", "观宇宙之大俯察品类之盛", "img/GuanYvZhouZhiDa.jpg",12,125,5);
ts.split(3, 0, "LiSao_JiTi", "既替余以蕙纕兮又申之以揽茝","img/JiTiYvYi.jpeg",50,165,5);
ts.split(6, 0, "LiSao_YiYv", "亦余心之所善兮虽九死其犹未悔", "img/YiYvXinZhi.jpeg", 25, 165, 4);
ts.split(3, 0, "ChiBi_ShiZao", "是造物者之", "img/ShiZaoWuZhe.jpeg", 15, 135, 5);
ts.split(9, 5, "ChiBi_ShiZao", "无尽藏也而吾与子之所共适", "img/ShiZaoWuZhe.jpeg", 20, 135, 5);
ts.split(2, 0, "ChiBi_ErDe", "耳得之而为声目遇之而成色", "img/ErDeZhiEr.jpeg", 20, 120, 4);
ts.split(10, 0, "Chibi_QingFeng", "清风徐来水波不兴", "img/QingFengXvLai.jpg", 100, 140, 4);
题外
到这里就是整个项目分享的尾声了。我很喜欢这个项目,是因为它的完成时间是我所有制作过的app中耗时最短的,也涉足了我之前从未尝试过的东西,包括view的绘制,openCV的使用;在制作项目的过程中有些焦虑,但最终还是有很好的效果。这么说是因为在这之后我开发的app都没有什么亮点也没有什么亮眼的效果。
这个项目是我在学校文化节数媒游戏展示的项目,主题关于中国汉字,开始制作的时间在4月初,展示是在5月中旬,最终以二等奖收尾(一开始我对我的项目是充满信心的,相信能够获得一等奖,但是后来发现有一组参赛者制作了一个开罗游戏,写了几个章节的情节,是他们的对项目的热情击败了我)。在项目分享的时候开头介绍app的时候,我才发现这是我学安卓开发以来第一次念出“Activity”这个单词,因为特别的紧张,单词我都没念准。
这个项目开始是以小组的形式报名的,但是结果是由个人独自完成的。希望这个app是对大学生活的一种告别,也希望自己能够一直向前走。