...
This commit is contained in:
parent
3a0a29ec02
commit
373964146c
@ -71,7 +71,7 @@ class SecurityConfig(
|
|||||||
csrf.ignoringRequestMatchers(
|
csrf.ignoringRequestMatchers(
|
||||||
"/user/login.bjx", "/user/joinUser.bjx","/tlg/repotToMe.bjx",
|
"/user/login.bjx", "/user/joinUser.bjx","/tlg/repotToMe.bjx",
|
||||||
"/blog/post/imageUpload.bjx", "/blog/post.bjx",
|
"/blog/post/imageUpload.bjx", "/blog/post.bjx",
|
||||||
"/blog/post/images/**"
|
"/blog/post/images/**","/puzzle/upload.bjx"
|
||||||
) // 여기 예외 추가
|
) // 여기 예외 추가
|
||||||
}.authorizeHttpRequests { auth ->
|
}.authorizeHttpRequests { auth ->
|
||||||
auth
|
auth
|
||||||
@ -84,6 +84,7 @@ class SecurityConfig(
|
|||||||
"/blog/viewer/**" , "/blog/posts" , "/blog/rankOfViews.bjx","/blog/recentOfPost.bjx",
|
"/blog/viewer/**" , "/blog/posts" , "/blog/rankOfViews.bjx","/blog/recentOfPost.bjx",
|
||||||
// "/blog/post/imageUpload.bjx",
|
// "/blog/post/imageUpload.bjx",
|
||||||
"/blog/post/images/**",
|
"/blog/post/images/**",
|
||||||
|
"/puzzle/upload.bjx",
|
||||||
"/css/**", "/js/**", "/images/**", "/webjars/**", "/assets/**").permitAll()
|
"/css/**", "/js/**", "/images/**", "/webjars/**", "/assets/**").permitAll()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
}.formLogin { form ->
|
}.formLogin { form ->
|
||||||
|
|||||||
@ -151,6 +151,7 @@ class BlogController() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@GetMapping("recentOfPost.bjx")
|
@GetMapping("recentOfPost.bjx")
|
||||||
fun recentOfPost(httpServletRequest: HttpServletRequest): Mono<ResponseEntity<PostsResult>> {
|
fun recentOfPost(httpServletRequest: HttpServletRequest): Mono<ResponseEntity<PostsResult>> {
|
||||||
logService.log(httpServletRequest.requestURI)
|
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.SQL=DEBUG
|
||||||
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
|
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