feat(rework): 화면 자막 OCR 에 자막영역(crop) 전달 추가
OCR 정확도·속도 향상을 위해 '자막영역 하단 N%' 입력을 추가, 플레이어 영상의 실제 해상도로 crop 픽셀(crop_x/y/width/height)을 환산해 /ocr_video 로 전달한다. 100%면 use_fullframe=true, 해상도 못 읽으면 생략(서버 기본 하단30%). - ocrFromCached(file, formParams Map)로 일반화 — 서버 필드명 그대로 전달 - CurationService/Controller에 useFullframe·cropX/Y/Width/Height 파라미터 추가 - rework.html: 자막영역 % 입력 + ocrScreen()이 영상 해상도로 crop 계산 검증: sample_fps=3 + 하단30% crop 으로 2m28s 완료(프록시 600s 적용 후 504 해소), crop 전달 정상 동작 확인. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f7ac96f1dd
commit
6a0a7294bb
@ -452,13 +452,12 @@ public class ChannelService {
|
|||||||
* 음성 전사와 같은 자리(ChannelVideoScript)에 저장 → 스크립트 리스트/SRT/번역에 그대로 흐른다.
|
* 음성 전사와 같은 자리(ChannelVideoScript)에 저장 → 스크립트 리스트/SRT/번역에 그대로 흐른다.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public ScriptResponseDto ocrFromCached(Long channelVideoId, File file, String lang,
|
public ScriptResponseDto ocrFromCached(Long channelVideoId, File file, java.util.Map<String, Object> formParams) {
|
||||||
Integer sampleFps, Integer confThreshold) {
|
|
||||||
ChannelVideo video = channelVideoRepository.findById(channelVideoId)
|
ChannelVideo video = channelVideoRepository.findById(channelVideoId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Video not found: " + channelVideoId));
|
.orElseThrow(() -> new IllegalArgumentException("Video not found: " + channelVideoId));
|
||||||
|
|
||||||
String apiUrl = pythonBaseUrl + "/ocr_video";
|
String apiUrl = pythonBaseUrl + "/ocr_video";
|
||||||
log.info("Requesting screen-subtitle OCR for video {} (lang={}, fps={})", channelVideoId, lang, sampleFps);
|
log.info("Requesting screen-subtitle OCR for video {} (params={})", channelVideoId, formParams);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
@ -466,12 +465,13 @@ public class ChannelService {
|
|||||||
|
|
||||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||||
body.add("file", toFileResource(file));
|
body.add("file", toFileResource(file));
|
||||||
if (lang != null && !lang.isBlank()) {
|
// 서버 필드명 그대로(lang, sample_fps, conf_threshold, use_fullframe, crop_x/y/width/height...).
|
||||||
body.add("lang", lang.trim());
|
// 빈 값은 보내지 않아 /ocr_video 기본값(하단30% crop, sample_fps=3, conf=60)을 따른다.
|
||||||
|
if (formParams != null) {
|
||||||
|
for (java.util.Map.Entry<String, Object> e : formParams.entrySet()) {
|
||||||
|
if (e.getValue() != null) body.add(e.getKey(), String.valueOf(e.getValue()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// null 이면 보내지 않아 Python /ocr_video 의 기본값(sample_fps=3, conf=60)을 따른다.
|
|
||||||
if (sampleFps != null) body.add("sample_fps", String.valueOf(sampleFps));
|
|
||||||
if (confThreshold != null) body.add("conf_threshold", String.valueOf(confThreshold));
|
|
||||||
|
|
||||||
HttpEntity<MultiValueMap<String, Object>> request = new HttpEntity<>(body, headers);
|
HttpEntity<MultiValueMap<String, Object>> request = new HttpEntity<>(body, headers);
|
||||||
ResponseEntity<String> response = pythonRestTemplate.postForEntity(apiUrl, request, String.class);
|
ResponseEntity<String> response = pythonRestTemplate.postForEntity(apiUrl, request, String.class);
|
||||||
|
|||||||
@ -178,8 +178,14 @@ public class ChannelVideoCurationController {
|
|||||||
public ApiResponse<Map<String, Object>> ocr(@PathVariable Long id,
|
public ApiResponse<Map<String, Object>> ocr(@PathVariable Long id,
|
||||||
@RequestParam(value = "lang", required = false) String lang,
|
@RequestParam(value = "lang", required = false) String lang,
|
||||||
@RequestParam(value = "sampleFps", required = false) Integer sampleFps,
|
@RequestParam(value = "sampleFps", required = false) Integer sampleFps,
|
||||||
@RequestParam(value = "confThreshold", required = false) Integer confThreshold) {
|
@RequestParam(value = "confThreshold", required = false) Integer confThreshold,
|
||||||
return ApiResponse.ok(curationService.ocrScreenSubtitles(id, lang, sampleFps, confThreshold));
|
@RequestParam(value = "useFullframe", required = false) Boolean useFullframe,
|
||||||
|
@RequestParam(value = "cropX", required = false) Integer cropX,
|
||||||
|
@RequestParam(value = "cropY", required = false) Integer cropY,
|
||||||
|
@RequestParam(value = "cropWidth", required = false) Integer cropWidth,
|
||||||
|
@RequestParam(value = "cropHeight", required = false) Integer cropHeight) {
|
||||||
|
return ApiResponse.ok(curationService.ocrScreenSubtitles(
|
||||||
|
id, lang, sampleFps, confThreshold, useFullframe, cropX, cropY, cropWidth, cropHeight));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/translate")
|
@PostMapping("/{id}/translate")
|
||||||
|
|||||||
@ -165,12 +165,26 @@ public class ChannelVideoCurationService {
|
|||||||
* 음성 전사와 같은 자리에 저장되어 스크립트 리스트/SRT/번역에 그대로 흐른다. (기존 스크립트는 덮어씀)
|
* 음성 전사와 같은 자리에 저장되어 스크립트 리스트/SRT/번역에 그대로 흐른다. (기존 스크립트는 덮어씀)
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public Map<String, Object> ocrScreenSubtitles(Long videoId, String lang, Integer sampleFps, Integer confThreshold) {
|
public Map<String, Object> ocrScreenSubtitles(Long videoId, String lang, Integer sampleFps, Integer confThreshold,
|
||||||
|
Boolean useFullframe, Integer cropX, Integer cropY,
|
||||||
|
Integer cropWidth, Integer cropHeight) {
|
||||||
ChannelVideo v = find(videoId);
|
ChannelVideo v = find(videoId);
|
||||||
java.io.File file = videoDownloadService.cachedFile(v.getId())
|
java.io.File file = videoDownloadService.cachedFile(v.getId())
|
||||||
.orElseThrow(() -> new IllegalArgumentException(
|
.orElseThrow(() -> new IllegalArgumentException(
|
||||||
"받은 원본이 없습니다. 먼저 '원본 다운로드'를 실행하세요."));
|
"받은 원본이 없습니다. 먼저 '원본 다운로드'를 실행하세요."));
|
||||||
ScriptResponseDto dto = channelService.ocrFromCached(v.getId(), file, lang, sampleFps, confThreshold);
|
|
||||||
|
// /ocr_video 서버 필드명으로 폼 조립(null 은 빼서 서버 기본값 사용).
|
||||||
|
Map<String, Object> form = new LinkedHashMap<>();
|
||||||
|
if (lang != null && !lang.isBlank()) form.put("lang", lang.trim());
|
||||||
|
if (sampleFps != null) form.put("sample_fps", sampleFps);
|
||||||
|
if (confThreshold != null) form.put("conf_threshold", confThreshold);
|
||||||
|
if (useFullframe != null) form.put("use_fullframe", useFullframe);
|
||||||
|
if (cropX != null) form.put("crop_x", cropX);
|
||||||
|
if (cropY != null) form.put("crop_y", cropY);
|
||||||
|
if (cropWidth != null) form.put("crop_width", cropWidth);
|
||||||
|
if (cropHeight != null) form.put("crop_height", cropHeight);
|
||||||
|
|
||||||
|
ScriptResponseDto dto = channelService.ocrFromCached(v.getId(), file, form);
|
||||||
|
|
||||||
Map<String, Object> result = new LinkedHashMap<>();
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
result.put("hasScript", true);
|
result.put("hasScript", true);
|
||||||
|
|||||||
@ -152,6 +152,11 @@
|
|||||||
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="ocrBtn" onclick="ocrScreen()" title="받은 원본 영상에 박힌 자막을 OCR로 추출(시간 싱크). 먼저 ‘원본 다운로드’ 필요">
|
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="ocrBtn" onclick="ocrScreen()" title="받은 원본 영상에 박힌 자막을 OCR로 추출(시간 싱크). 먼저 ‘원본 다운로드’ 필요">
|
||||||
<i data-lucide="scan-text" style="width:15px;"></i> 화면 자막 OCR
|
<i data-lucide="scan-text" style="width:15px;"></i> 화면 자막 OCR
|
||||||
</button>
|
</button>
|
||||||
|
<span class="text-xs text-muted" title="OCR 대상 영역(하단). 좁힐수록 정확·빠름. 100=전체화면">자막영역 하단</span>
|
||||||
|
<input id="ocrCropPct" type="number" value="30" min="10" max="100" step="5"
|
||||||
|
title="OCR 대상 영역(하단 %). 100=전체화면"
|
||||||
|
style="width:54px; padding:6px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:6px; color:var(--text); font-size:0.85rem;">
|
||||||
|
<span class="text-xs text-muted">%</span>
|
||||||
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="copyBtn" onclick="copyToEditor()">
|
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="copyBtn" onclick="copyToEditor()">
|
||||||
<i data-lucide="copy" style="width:15px;"></i> 에디터로 복사
|
<i data-lucide="copy" style="width:15px;"></i> 에디터로 복사
|
||||||
</button>
|
</button>
|
||||||
@ -646,7 +651,19 @@
|
|||||||
status.textContent = '화면 자막 OCR 중… (프레임 분석, 영상 길이에 따라 수십 초~)';
|
status.textContent = '화면 자막 OCR 중… (프레임 분석, 영상 길이에 따라 수십 초~)';
|
||||||
try {
|
try {
|
||||||
const lang = document.getElementById('langSel').value || 'ko';
|
const lang = document.getElementById('langSel').value || 'ko';
|
||||||
const s = await api(API + '/' + VIDEO_ID + '/ocr?lang=' + encodeURIComponent(lang), { method:'POST' });
|
let q = '?lang=' + encodeURIComponent(lang);
|
||||||
|
// 자막영역(하단 N%)을 영상 실제 해상도로 환산해 crop 전달 → 정확도·속도↑
|
||||||
|
const pct = Math.min(100, Math.max(10, parseInt(document.getElementById('ocrCropPct').value) || 30));
|
||||||
|
const v = document.getElementById('localVideo');
|
||||||
|
const vw = v ? v.videoWidth : 0, vh = v ? v.videoHeight : 0;
|
||||||
|
if (pct >= 100) {
|
||||||
|
q += '&useFullframe=true';
|
||||||
|
} else if (vw > 0 && vh > 0) {
|
||||||
|
const ch = Math.round(vh * pct / 100);
|
||||||
|
q += '&useFullframe=false&cropX=0&cropWidth=' + vw + '&cropHeight=' + ch + '&cropY=' + (vh - ch);
|
||||||
|
}
|
||||||
|
// 영상 해상도를 못 읽으면 crop 생략 → 서버 기본(하단 30%) 적용
|
||||||
|
const s = await api(API + '/' + VIDEO_ID + '/ocr' + q, { method:'POST' });
|
||||||
renderSegments(s.segments);
|
renderSegments(s.segments);
|
||||||
document.getElementById('transcript').value = s.transcript || '';
|
document.getElementById('transcript').value = s.transcript || '';
|
||||||
status.style.color = '#4ade80';
|
status.style.color = '#4ade80';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user