...
This commit is contained in:
parent
3a0a29ec02
commit
373964146c
@ -71,7 +71,7 @@ class SecurityConfig(
|
||||
csrf.ignoringRequestMatchers(
|
||||
"/user/login.bjx", "/user/joinUser.bjx","/tlg/repotToMe.bjx",
|
||||
"/blog/post/imageUpload.bjx", "/blog/post.bjx",
|
||||
"/blog/post/images/**"
|
||||
"/blog/post/images/**","/puzzle/upload.bjx"
|
||||
) // 여기 예외 추가
|
||||
}.authorizeHttpRequests { auth ->
|
||||
auth
|
||||
@ -84,6 +84,7 @@ class SecurityConfig(
|
||||
"/blog/viewer/**" , "/blog/posts" , "/blog/rankOfViews.bjx","/blog/recentOfPost.bjx",
|
||||
// "/blog/post/imageUpload.bjx",
|
||||
"/blog/post/images/**",
|
||||
"/puzzle/upload.bjx",
|
||||
"/css/**", "/js/**", "/images/**", "/webjars/**", "/assets/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
}.formLogin { form ->
|
||||
|
||||
@ -151,6 +151,7 @@ class BlogController() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("recentOfPost.bjx")
|
||||
fun recentOfPost(httpServletRequest: HttpServletRequest): Mono<ResponseEntity<PostsResult>> {
|
||||
logService.log(httpServletRequest.requestURI)
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
package kr.lunaticbum.back.lun.controllers
|
||||
|
||||
import kr.lunaticbum.back.lun.model.PuzzleService
|
||||
import kr.lunaticbum.back.lun.model.ResultMV
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/puzzle")
|
||||
class PuzzleController(private val puzzleService: PuzzleService) { // 생성자 주입
|
||||
|
||||
@PostMapping("upload.bjx")
|
||||
suspend fun createPuzzleFromImage(@RequestParam("imageFile") imageFile: MultipartFile): ResponseEntity<Any> {
|
||||
return try {
|
||||
val savedPuzzle = puzzleService.generateAndSavePuzzle(imageFile, 20)
|
||||
ResponseEntity.ok(savedPuzzle) // 성공 시 200 OK와 함께 결과 반환
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
// 실패 시 500 에러와 메시지 반환
|
||||
ResponseEntity.internalServerError().body("이미지 처리 중 오류 발생: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/","/upload.bs")
|
||||
suspend fun uploadPuzzle() : ResultMV {
|
||||
val vm = ResultMV("content/puzzle/upload")
|
||||
return vm
|
||||
}
|
||||
}
|
||||
140
src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt
Normal file
140
src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt
Normal file
@ -0,0 +1,140 @@
|
||||
package kr.lunaticbum.back.lun.model
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.reactor.awaitSingle
|
||||
import kotlinx.coroutines.withContext
|
||||
import kr.lunaticbum.back.lun.utils.ImageUtils.convertTransparentToWhite
|
||||
import org.springframework.data.annotation.Id
|
||||
import org.springframework.data.mongodb.core.mapping.Document
|
||||
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import java.awt.Color
|
||||
import java.awt.image.BufferedImage
|
||||
import java.time.LocalDateTime
|
||||
import javax.imageio.ImageIO
|
||||
|
||||
|
||||
@Document("puzzles") // "puzzles" 컬렉션에 매핑
|
||||
data class NonogramPuzzle(
|
||||
@Id
|
||||
val id: String? = null, // MongoDB가 생성하므로 nullable(null 가능) 및 var로 선언
|
||||
val solutionGrid: List<List<Int>>,
|
||||
val rowClues: List<List<Int>>,
|
||||
val colClues: List<List<Int>>,
|
||||
val createdAt: LocalDateTime = LocalDateTime.now() // 생성 시점의 기본값 설정
|
||||
)
|
||||
|
||||
@Repository
|
||||
interface NonogramPuzzleRepository : ReactiveMongoRepository<NonogramPuzzle, String> {
|
||||
// ReactiveMongoRepository가 모든 기본 CRUD 기능을 반응형으로 제공
|
||||
}
|
||||
|
||||
@Service
|
||||
class PuzzleService(private val puzzleRepository: NonogramPuzzleRepository) { // 생성자 주입
|
||||
|
||||
// suspend 함수로 비동기 작업을 선언
|
||||
suspend fun generateAndSavePuzzle(file: MultipartFile, size: Int): NonogramPuzzle {
|
||||
val puzzleData = withContext(Dispatchers.IO) {
|
||||
val originalImage = ImageIO.read(file.inputStream)
|
||||
|
||||
// 1. (★중요) 투명 배경을 흰색으로 먼저 변환합니다.
|
||||
val imageWithBackground = convertTransparentToWhite(originalImage)
|
||||
|
||||
// 2. 그레이스케일 변환 (흰색 배경이 적용된 이미지를 사용)
|
||||
val grayImage = BufferedImage(size, size, BufferedImage.TYPE_BYTE_GRAY).apply {
|
||||
createGraphics().run {
|
||||
drawImage(imageWithBackground, 0, 0, size, size, null)
|
||||
dispose()
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 평균 밝기를 계산하여 동적 임계치 결정
|
||||
val averageBrightness = calculateAverageBrightness(grayImage)
|
||||
val adaptiveThreshold = determineAdaptiveThreshold(averageBrightness)
|
||||
|
||||
// 4. 결정된 임계치로 최종 그리드 생성
|
||||
val solutionGrid = List(size) { y ->
|
||||
List(size) { x ->
|
||||
if (grayImage.raster.getSample(x, y, 0) < adaptiveThreshold) 1 else 0
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 힌트 추출 및 객체 생성
|
||||
val rowClues = solutionGrid.map { getCluesForLine(it) }
|
||||
val colClues = transpose(solutionGrid).map { getCluesForLine(it) }
|
||||
|
||||
NonogramPuzzle(solutionGrid = solutionGrid, rowClues = rowClues, colClues = colClues)
|
||||
}
|
||||
|
||||
return puzzleRepository.save(puzzleData).awaitSingle()
|
||||
}
|
||||
|
||||
/**
|
||||
* (★추가된 함수) 투명한 배경을 가진 BufferedImage를 흰색 배경으로 변환합니다.
|
||||
*/
|
||||
private fun convertTransparentToWhite(sourceImage: BufferedImage): BufferedImage {
|
||||
if (!sourceImage.colorModel.hasAlpha()) {
|
||||
return sourceImage
|
||||
}
|
||||
|
||||
return BufferedImage(sourceImage.width, sourceImage.height, BufferedImage.TYPE_INT_RGB).apply {
|
||||
createGraphics().also { g2d ->
|
||||
g2d.color = Color.WHITE
|
||||
g2d.fillRect(0, 0, width, height)
|
||||
g2d.drawImage(sourceImage, 0, 0, null)
|
||||
g2d.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 그레이스케일 이미지의 평균 밝기를 계산합니다. (0~255)
|
||||
*/
|
||||
private fun calculateAverageBrightness(image: BufferedImage): Int {
|
||||
var totalBrightness: Long = 0
|
||||
val width = image.width
|
||||
val height = image.height
|
||||
for (y in 0 until height) {
|
||||
for (x in 0 until width) {
|
||||
totalBrightness += image.raster.getSample(x, y, 0)
|
||||
}
|
||||
}
|
||||
return (totalBrightness / (width * height)).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* 평균 밝기에 따라 임계치를 조정합니다.
|
||||
*/
|
||||
private fun determineAdaptiveThreshold(averageBrightness: Int): Int {
|
||||
return when {
|
||||
// 이미지가 매우 밝으면 (평균 180 이상), 임계치를 평균보다 약간 낮춰 어두운 부분을 더 잘 잡아냄
|
||||
averageBrightness > 180 -> (averageBrightness * 0.9).toInt()
|
||||
// 이미지가 매우 어두우면 (평균 80 이하), 임계치를 평균보다 약간 높여 밝은 부분을 더 잘 잡아냄
|
||||
averageBrightness < 80 -> (averageBrightness * 1.1).toInt()
|
||||
// 보통 밝기의 이미지면 평균값을 그대로 사용
|
||||
else -> averageBrightness
|
||||
}
|
||||
}
|
||||
|
||||
// --- 헬퍼 함수들 ---
|
||||
private fun getCluesForLine(line: List<Int>): List<Int> {
|
||||
val clues = mutableListOf<Int>()
|
||||
var count = 0
|
||||
for (cell in line) {
|
||||
if (cell == 1) {
|
||||
count++
|
||||
} else if (count > 0) {
|
||||
clues.add(count)
|
||||
count = 0
|
||||
}
|
||||
}
|
||||
if (count > 0) clues.add(count)
|
||||
return if (clues.isEmpty()) listOf(0) else clues
|
||||
}
|
||||
|
||||
private fun transpose(grid: List<List<Int>>): List<List<Int>> {
|
||||
return List(grid[0].size) { j -> List(grid.size) { i -> grid[i][j] } }
|
||||
}
|
||||
}
|
||||
43
src/main/kotlin/kr/lunaticbum/back/lun/utils/ImageUtils.kt
Normal file
43
src/main/kotlin/kr/lunaticbum/back/lun/utils/ImageUtils.kt
Normal file
@ -0,0 +1,43 @@
|
||||
package kr.lunaticbum.back.lun.utils
|
||||
|
||||
import java.awt.Color
|
||||
import java.awt.image.BufferedImage
|
||||
|
||||
|
||||
object ImageUtils {
|
||||
/**
|
||||
* 투명한 배경을 가진 BufferedImage를 흰색 배경으로 변환합니다.
|
||||
* @param sourceImage 원본 이미지
|
||||
* @return 흰색 배경이 적용된 새 BufferedImage
|
||||
*/
|
||||
fun convertTransparentToWhite(sourceImage: BufferedImage): BufferedImage {
|
||||
// 원본 이미지에 알파 채널이 없으면 그대로 반환
|
||||
if (!sourceImage.getColorModel().hasAlpha()) {
|
||||
return sourceImage
|
||||
}
|
||||
|
||||
// 원본과 같은 크기, 하지만 알파 채널이 없는(RGB) 새 이미지를 생성
|
||||
val newImage = BufferedImage(
|
||||
sourceImage.getWidth(),
|
||||
sourceImage.getHeight(),
|
||||
BufferedImage.TYPE_INT_RGB // 알파 채널이 없는 타입
|
||||
)
|
||||
|
||||
// 새 이미지의 Graphics2D 컨텍스트를 얻음 (그림을 그릴 도구)
|
||||
val g2d = newImage.createGraphics()
|
||||
|
||||
try {
|
||||
// 1. 새 이미지의 배경을 흰색으로 완전히 칠합니다.
|
||||
g2d.setColor(Color.WHITE)
|
||||
g2d.fillRect(0, 0, newImage.getWidth(), newImage.getHeight())
|
||||
|
||||
// 2. 흰색 배경 위에 원본 이미지를 그립니다. (투명한 부분은 흰색 배경이 비침)
|
||||
g2d.drawImage(sourceImage, 0, 0, null)
|
||||
} finally {
|
||||
// 리소스를 해제합니다.
|
||||
g2d.dispose()
|
||||
}
|
||||
|
||||
return newImage
|
||||
}
|
||||
}
|
||||
@ -96,3 +96,7 @@ spring.jpa.properties.hibernate.format_sql=true
|
||||
logging.level.org.hibernate.SQL=DEBUG
|
||||
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
|
||||
|
||||
# Increase server connection timeout to 60 seconds (default is often 20 or 30s)
|
||||
server.tomcat.connection-timeout=60s
|
||||
# For reactive applications (like yours), also set this timeout
|
||||
spring.webflux.response-timeout=60s
|
||||
41
src/main/resources/static/css/puzzle.css
Normal file
41
src/main/resources/static/css/puzzle.css
Normal file
@ -0,0 +1,41 @@
|
||||
#puzzle-container {
|
||||
display: grid;
|
||||
/* We will set grid-template-columns/rows with JS */
|
||||
grid-gap: 2px;
|
||||
margin-top: 20px;
|
||||
background-color: #333;
|
||||
border: 2px solid #333;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.grid-cell {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
background-color: #f0f0f0;
|
||||
text-align: center;
|
||||
line-height: 25px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.clue-cell {
|
||||
background-color: #cce7ff;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
min-height: 25px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.solution-cell {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
}
|
||||
|
||||
.filled {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.empty {
|
||||
background-color: #fff;
|
||||
}
|
||||
88
src/main/resources/static/js/upload.js
Normal file
88
src/main/resources/static/js/upload.js
Normal file
@ -0,0 +1,88 @@
|
||||
function drawPuzzle(puzzleData) {
|
||||
const container = document.getElementById('puzzle-container');
|
||||
container.innerHTML = ''; // Clear previous puzzle
|
||||
|
||||
const { solutionGrid, rowClues, colClues } = puzzleData;
|
||||
const numRows = solutionGrid.length;
|
||||
const numCols = solutionGrid[0].length;
|
||||
|
||||
// Set up the CSS Grid layout
|
||||
container.style.gridTemplateColumns = `auto repeat(${numCols}, 1fr)`;
|
||||
container.style.gridTemplateRows = `auto repeat(${numRows}, 1fr)`;
|
||||
|
||||
// 1. Create top-left empty corner
|
||||
const corner = document.createElement('div');
|
||||
corner.className = 'grid-cell';
|
||||
container.appendChild(corner);
|
||||
|
||||
// 2. Create column clues (top row)
|
||||
for (const clues of colClues) {
|
||||
const clueCell = document.createElement('div');
|
||||
clueCell.className = 'clue-cell';
|
||||
clueCell.innerHTML = clues.join('<br>'); // Display clues vertically
|
||||
container.appendChild(clueCell);
|
||||
}
|
||||
|
||||
// 3. Create row clues and the solution grid
|
||||
for (let i = 0; i < numRows; i++) {
|
||||
// Create row clue cell for this row
|
||||
const rowClueCell = document.createElement('div');
|
||||
rowClueCell.className = 'clue-cell';
|
||||
rowClueCell.textContent = rowClues[i].join(' ');
|
||||
container.appendChild(rowClueCell);
|
||||
|
||||
// Create the solution cells for this row
|
||||
for (let j = 0; j < numCols; j++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'solution-cell';
|
||||
if (solutionGrid[i][j] === 1) {
|
||||
cell.classList.add('filled'); // Black square
|
||||
} else {
|
||||
cell.classList.add('empty'); // White square
|
||||
}
|
||||
container.appendChild(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Modify your existing click listener
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('createBtn').addEventListener('click', async () => {
|
||||
const uploader = document.getElementById('imageUploader');
|
||||
const statusDiv = document.getElementById('status');
|
||||
const puzzleContainer = document.getElementById('puzzle-container');
|
||||
|
||||
if (uploader.files.length === 0) {
|
||||
statusDiv.textContent = '이미지 파일을 선택해주세요.';
|
||||
return;
|
||||
}
|
||||
|
||||
const imageFile = uploader.files[0];
|
||||
const formData = new FormData();
|
||||
formData.append('imageFile', imageFile);
|
||||
|
||||
statusDiv.textContent = '문제를 생성하는 중...';
|
||||
puzzleContainer.innerHTML = ''; // Clear old puzzle while loading
|
||||
|
||||
try {
|
||||
const response = await fetch(getMainPath()+'/puzzle/upload.bjx', { // Make sure this URL is correct
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const puzzleData = await response.json();
|
||||
statusDiv.textContent = '문제 생성 성공!';
|
||||
|
||||
// Call the new function to draw the puzzle!
|
||||
drawPuzzle(puzzleData);
|
||||
|
||||
} else {
|
||||
const errorMessage = await response.text();
|
||||
statusDiv.textContent = `생성 실패: ${errorMessage}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('네트워크 오류:', error);
|
||||
statusDiv.textContent = '서버와 통신 중 오류가 발생했습니다.';
|
||||
}
|
||||
});
|
||||
});
|
||||
30
src/main/resources/templates/content/puzzle/upload.html
Normal file
30
src/main/resources/templates/content/puzzle/upload.html
Normal file
@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
||||
layout:decorate="~{layout/default_layout}"
|
||||
>
|
||||
<th:block layout:fragment="head" id="head">
|
||||
<script type="text/javascript" th:src="@{/js/upload.js}"></script>
|
||||
<link th:href="@{/css/puzzle.css}" rel="stylesheet" />
|
||||
<script th:inline="javascript">
|
||||
|
||||
</script>
|
||||
<script type="text/javascript" th:src="@{/js/user.js}"></script>
|
||||
</th:block >
|
||||
<th:block layout:fragment="content">
|
||||
<div id="puzzle-container">
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h1>이미지로 네모로직 문제 만들기</h1>
|
||||
<input type="file" id="imageUploader" accept="image/*">
|
||||
<button id="createBtn">문제 생성</button>
|
||||
<div id="status"></div>
|
||||
<hr>
|
||||
<h2>결과</h2>
|
||||
<div id="result"></div>
|
||||
|
||||
</th:block>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user