This commit is contained in:
lunaticbum 2025-08-29 18:01:38 +09:00
parent 3a0a29ec02
commit 373964146c
9 changed files with 383 additions and 1 deletions

View File

@ -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 ->

View File

@ -151,6 +151,7 @@ class BlogController() {
}
}
@GetMapping("recentOfPost.bjx")
fun recentOfPost(httpServletRequest: HttpServletRequest): Mono<ResponseEntity<PostsResult>> {
logService.log(httpServletRequest.requestURI)

View File

@ -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
}
}

View 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] } }
}
}

View 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
}
}

View File

@ -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

View 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;
}

View 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 = '서버와 통신 중 오류가 발생했습니다.';
}
});
});

View 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>