feat(assessment): 添加图片分析及生成摸底测试文档功能

- 新增AssessmentConstant常量接口,定义文件暂存目录及列数常量
- AssessmentController新增上传图片分析接口,支持将上传的PNG文件暂存并解析坐标
- 新增CoordinatesXY数据模型,封装坐标及宽高信息
- 引入OpenCV依赖,新增PngUtil工具类,实现黑色块检测并计算坐标列表
- PngUtil实现对未背熟单词的图片标记分析方法
- 优化AssessmentController使用新版Word模板文件assessment_v3.docx
- 删除冗余旧的StudentServiceImpl代码,整合至student包内实现
- 迁移和完善StudentServiceImpl,实现学生分页查询及总数统计接口
This commit is contained in:
lbw
2025-12-12 11:51:30 +08:00
parent d424f72183
commit b01810191e
13 changed files with 293 additions and 36 deletions

View File

@@ -95,6 +95,11 @@
<artifactId>poi-tl</artifactId>
</dependency>
<dependency>
<groupId>org.openpnp</groupId>
<artifactId>opencv</artifactId>
</dependency>
</dependencies>
<build>

View File

@@ -0,0 +1,8 @@
package com.yinlihupo.enlish.service.constant;
public interface AssessmentConstant {
int PGN_COL = 53;
// 文件暂存目录 linux
String ASSESSMENT_FELT = "enlish/assessment/";
}

View File

@@ -4,18 +4,20 @@ package com.yinlihupo.enlish.service.controller;
import com.deepoove.poi.XWPFTemplate;
import com.deepoove.poi.config.Configure;
import com.deepoove.poi.plugin.table.LoopRowTableRenderPolicy;
import com.yinlihupo.enlish.service.constant.AssessmentConstant;
import com.yinlihupo.enlish.service.enums.AssessmentsType;
import com.yinlihupo.enlish.service.model.bo.CoordinatesXY;
import com.yinlihupo.enlish.service.model.bo.Word;
import com.yinlihupo.enlish.service.model.vo.assessment.AssessmentStudentReqVO;
import com.yinlihupo.enlish.service.service.AssessmentService;
import com.yinlihupo.enlish.service.utils.PngUtil;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
@@ -57,7 +59,7 @@ public class AssessmentController {
data.put("answer", assessmentWords);
// 4. 渲染并输出
try (XWPFTemplate template = XWPFTemplate.compile("C:\\project\\java\\enlish_edu\\enlish\\enlish-service\\src\\main\\resources\\templates\\assessment.docx", config)) {
try (XWPFTemplate template = XWPFTemplate.compile("C:\\project\\java\\enlish_edu\\enlish\\enlish-service\\src\\main\\resources\\templates\\assessment_v3.docx", config)) {
template.render(data);
String fileName = URLEncoder.encode( assessmentDocxId + "摸底测试.docx", StandardCharsets.UTF_8).replaceAll("\\+", "%20");
response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
@@ -72,6 +74,18 @@ public class AssessmentController {
throw new RuntimeException(e);
}
}
@PostMapping("analyze")
public void generateAssessmentDocxAnalyze(@RequestParam("file") MultipartFile file, @RequestParam("id") Integer assessmentId) {
// 把文件暂存在本地
String filePath = AssessmentConstant.ASSESSMENT_FELT + assessmentId + ".png";
try {
file.transferTo(new File(filePath));
} catch (Exception e) {
throw new RuntimeException(e);
}
List<CoordinatesXY> coordinatesXIES = PngUtil.analysisXY(filePath);
}
}

View File

@@ -0,0 +1,30 @@
package com.yinlihupo.enlish.service.model.bo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class CoordinatesXY {
int x;
int y;
int width;
int height;
// x 从左往右, y 从上往下
// (x, y) ←─── 左上角起点
// ↓
// +--------------------------------------+
// | |
// | 宽 (Width) | 从左到右
// | |
// +--------------------------------------+
//
// ↕
// 高 (Height)
// 从上到下
}

View File

@@ -1,28 +0,0 @@
package com.yinlihupo.enlish.service.service.assessment;
import com.yinlihupo.enlish.service.domain.dataobject.StudentDO;
import com.yinlihupo.enlish.service.domain.mapper.StudentDOMapper;
import com.yinlihupo.enlish.service.service.StudentService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class StudentServiceImpl implements StudentService {
@Resource
private StudentDOMapper studentDOMapper;
@Override
public List<StudentDO> getStudentsByClassIdAndGradeId(Integer classId, Integer gradeId, Integer pageNo, Integer pageSize) {
return studentDOMapper.selectStudentDOListByClassIdAndGradeId(classId, gradeId, pageSize, (pageNo - 1) * pageSize);
}
@Override
public int getAllStudents() {
return studentDOMapper.selectStudentCount();
}
}

View File

@@ -1,12 +1,28 @@
package com.yinlihupo.enlish.service.service.student;
import lombok.extern.slf4j.Slf4j;
import com.yinlihupo.enlish.service.domain.dataobject.StudentDO;
import com.yinlihupo.enlish.service.domain.mapper.StudentDOMapper;
import com.yinlihupo.enlish.service.service.StudentService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@Slf4j
public class StudentServiceImpl {
public class StudentServiceImpl implements StudentService {
@Resource
private StudentDOMapper studentDOMapper;
@Override
public List<StudentDO> getStudentsByClassIdAndGradeId(Integer classId, Integer gradeId, Integer pageNo, Integer pageSize) {
return studentDOMapper.selectStudentDOListByClassIdAndGradeId(classId, gradeId, pageSize, (pageNo - 1) * pageSize);
}
@Override
public int getAllStudents() {
return studentDOMapper.selectStudentCount();
}
}

View File

@@ -0,0 +1,204 @@
package com.yinlihupo.enlish.service.utils;
import com.yinlihupo.enlish.service.constant.AssessmentConstant;
import com.yinlihupo.enlish.service.model.bo.CoordinatesXY;
import com.yinlihupo.enlish.service.model.bo.Word;
import lombok.extern.slf4j.Slf4j;
import nu.pattern.OpenCV;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.opencv.core.*;
import org.opencv.imgcodecs.Imgcodecs;
import org.opencv.imgproc.Imgproc;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
@Slf4j
public class PngUtil {
static {
OpenCV.loadLocally();
}
// 获取起始坐标
public static List<CoordinatesXY> analysisXY(String imagePath) {
Mat src = Imgcodecs.imread(imagePath);
if (src.empty()) {
System.out.println("无法读取图片,请检查路径。");
return null;
}
// 3. 预处理
// 3.1 转换为灰度图
Mat gray = new Mat();
Imgproc.cvtColor(src, gray, Imgproc.COLOR_BGR2GRAY);
// 3.2 二值化处理 (Thresholding)
// 使用 THRESH_BINARY_INV (反转二值化),因为我们需要找的是白色背景上的黑色块。
// 反转后,黑色块变成白色(255),背景变成黑色(0),方便 findContours 查找。
Mat binary = new Mat();
// 阈值设为 50 左右即可,因为块是纯黑的
Imgproc.threshold(gray, binary, 50, 255, Imgproc.THRESH_BINARY_INV);
// 4. 查找轮廓
List<MatOfPoint> contours = new ArrayList<>();
Mat hierarchy = new Mat();
// RETR_EXTERNAL 只检测最外层轮廓,忽略块内部可能存在的噪点
Imgproc.findContours(binary, contours, hierarchy, Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE);
System.out.println("检测到的轮廓总数: " + contours.size());
System.out.println("------------------------------------------------");
// 5. 遍历并筛选轮廓
int blockCount = 0;
// 设定最小面积阈值,过滤掉文字、表格细线等噪点
// 根据图片分辨率,这两个块看起来比较大,我们可以设一个较大的值,例如 500 或 1000 像素
double minArea = 1000.0;
List<CoordinatesXY> list = new ArrayList<>();
for (int i = 0; i < contours.size(); i++) {
MatOfPoint contour = contours.get(i);
// 计算轮廓面积
double area = Imgproc.contourArea(contour);
// 获取边界矩形
Rect rect = Imgproc.boundingRect(contour);
// 筛选条件:
// 1. 面积必须足够大 (排除文字)
// 2. 宽高比不能太极端 (排除长长的表格横线或竖线)
// 3. 宽度和高度必须有一定的尺寸
if (area > minArea && rect.width > 20 && rect.height > 20) {
blockCount++;
System.out.println("找到黑色块 #" + blockCount);
System.out.println("坐标 (X, Y): (" + rect.x + ", " + rect.y + ")");
System.out.println("尺寸 (宽 x 高): " + rect.width + " x " + rect.height);
System.out.println("中心点: (" + (rect.x + rect.width / 2) + ", " + (rect.y + rect.height / 2) + ")");
System.out.println("------------------------------------------------");
list.add(CoordinatesXY.builder().x(rect.x).y(rect.y).width(rect.width).height(rect.height).build());
// 可选:在原图上画出框,用于调试验证
// Imgproc.rectangle(src, rect, new Scalar(0, 0, 255), 2); // 红色框
// Imgproc.putText(src, "#" + blockCount, new Point(rect.x, rect.y - 5), Imgproc.FONT_HERSHEY_SIMPLEX, 0.5, new Scalar(0, 0, 255), 1);
// Imgcodecs.imwrite("output_red.png", src);
}
}
System.out.println("找到 " + blockCount + " 个黑色块。");
// 获取每一列的宽度
list.sort(Comparator.comparingInt(CoordinatesXY::getHeight));
int height = list.get(list.size() - 1).getHeight() / AssessmentConstant.PGN_COL;
// 删除两列答题卡区块
list.sort(Comparator.comparingInt(CoordinatesXY::getWidth));
list.remove(list.size() - 1);
list.remove(list.size() - 1);
list.sort(Comparator.comparingInt(CoordinatesXY::getX));
// 计算起始坐标
List<CoordinatesXY> ans = getCoordinatesXIES(list, height);
src.release();
binary.release();
hierarchy.release();
binary.release();
return ans;
}
// 获取(未背熟)单词的 id
public static List<Integer> analyzePngForUnmemorizedWordIds(String filePath, List<Word> words, List<CoordinatesXY> coordinatesXYList) {
Mat src = Imgcodecs.imread(filePath);
if (src.empty()) {
log.error("无法读取图片,请检查路径: {}", filePath);
throw new RuntimeException("无法读取图片");
}
Mat gray = new Mat();
Mat binary = new Mat();
try {
Imgproc.cvtColor(src, gray, Imgproc.COLOR_BGR2GRAY);
// 建议:如果光照不均匀,考虑使用 THRESH_OTSU 自动阈值,或者自适应阈值
Imgproc.threshold(gray, binary, 150, 255, Imgproc.THRESH_BINARY_INV);
// 调试时打印
// Imgcodecs.imwrite("output_binary.png", binary);
List<Integer> answer = new ArrayList<>();
int words_index = 0;
for (int i = 0; i < coordinatesXYList.size(); i++) {
CoordinatesXY coordinatesXY = coordinatesXYList.get(i);
int width = coordinatesXY.getWidth();
int height = coordinatesXY.getHeight();
int currentX = coordinatesXY.getX();
int currentY = coordinatesXY.getY();
int count = i == 0 ? AssessmentConstant.PGN_COL - 1 : AssessmentConstant.PGN_COL;
// 内层循环:遍历这一列的每一行
for (int j = 0; j < count; j++) {
// 安全检查:防止单词列表比格子少导致越界
if (words_index >= words.size()) {
log.warn("单词列表耗尽,停止检测。格子数多于单词数。");
break;
}
log.info("当前坐标 x = {} y = {}", currentX, currentY);
// 安全检查:防止 Rect 超出图片边界
if (currentX < 0 || currentY < 0 ||
currentX + width > binary.cols() || currentY + height > binary.rows()) {
log.warn("检测区域越界,跳过: x={}, y={}", currentX, currentY);
words_index++;
currentY += height; // 依然要移动坐标
continue;
}
Rect rect = new Rect(currentX + 1, currentY + 1, width - 2, height - 2);
Mat region = binary.submat(rect);
int countNonZero = Core.countNonZero(region);
if (countNonZero > 800) {
Word currentWord = words.get(words_index);
answer.add(currentWord.getId());
log.info("检测到标记未背熟ID={}, 词={}", currentWord.getId(), currentWord.getTitle());
}
region.release();
words_index++;
currentY += height;
}
}
return answer;
} finally {
src.release();
gray.release();
binary.release();
}
}
private static @NonNull List<CoordinatesXY> getCoordinatesXIES(List<CoordinatesXY> list, int height) {
List<CoordinatesXY> ans = new ArrayList<>();
CoordinatesXY left = new CoordinatesXY();
left.setX(list.get(1).getX());
left.setWidth(list.get(1).getWidth());
left.setHeight(height);
left.setY(list.get(0).getY() + left.getHeight());
ans.add(left);
CoordinatesXY right = new CoordinatesXY();
right.setX(list.get(2).getX());
right.setY(list.get(0).getY());
right.setWidth(list.get(1).getWidth());
right.setHeight(height);
ans.add(right);
return ans;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

View File

@@ -109,6 +109,14 @@
<version>1.12.1</version>
</dependency>
<dependency>
<groupId>org.openpnp</groupId>
<artifactId>opencv</artifactId>
<version>4.7.0-0</version>
</dependency>
<!-- 避免编写那些冗余的 Java 样板式代码,如 get、set 等 -->
<dependency>
<groupId>org.projectlombok</groupId>