Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 41 additions & 17 deletions violet-search/ocr_article.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,38 @@ def group_into_dialogues(
return dialogues


def make_ocr_config(
device="gpu:0",
threshold=0.5,
rec_batch_size=16,
det_model="PP-OCRv5_mobile_det",
rec_model="korean_PP-OCRv5_mobile_rec",
) -> dict:
"""OCR 설정 dict 생성"""
return {
"device": device,
"threshold": threshold,
"rec_batch_size": rec_batch_size,
"det_model": det_model,
"rec_model": rec_model,
}


def create_ocr_pool(workers: int, config: dict) -> Pool:
"""OCR 워커 Pool 생성 (모델 로딩 1회)"""
return Pool(processes=workers, initializer=_worker_init, initargs=(config,))


def save_ocr_result(result: dict, output_dir: str) -> str:
"""OCR 결과를 JSON 파일로 저장. 저장 경로 반환."""
os.makedirs(output_dir, exist_ok=True)
article_id = result["articleId"]
output_path = os.path.join(output_dir, f"{article_id}.json")
with open(output_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
return output_path


def parse_args():
parser = argparse.ArgumentParser(
description="Article OCR - 페이지별 대사 추출 (멀티프로세스)"
Expand Down Expand Up @@ -338,24 +370,20 @@ def main():
for page_num, target, (w, h) in zip(page_nums, target_paths, dimensions)
]

worker_config = {
"device": args.device,
"threshold": args.threshold,
"rec_batch_size": args.rec_batch_size,
"det_model": args.det_model,
"rec_model": args.rec_model,
}
worker_config = make_ocr_config(
device=args.device,
threshold=args.threshold,
rec_batch_size=args.rec_batch_size,
det_model=args.det_model,
rec_model=args.rec_model,
)

total = len(tasks)
print(f"OCR 실행 중 ({total}장, {args.workers} 워커)...")

t2 = time.perf_counter()

with Pool(
processes=args.workers,
initializer=_worker_init,
initargs=(worker_config,),
) as pool:
with create_ocr_pool(args.workers, worker_config) as pool:
results_unordered = []
for i, result in enumerate(pool.imap_unordered(_worker_process_image, tasks)):
results_unordered.append(result)
Expand Down Expand Up @@ -387,11 +415,7 @@ def main():

script_dir = os.path.dirname(os.path.abspath(__file__))
output_dir = args.output_dir if args.output_dir else os.path.join(script_dir, "raw")
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, f"{article_id}.json")

with open(output_path, "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2)
output_path = save_ocr_result(output, output_dir)

# 임시 디렉토리 정리
import shutil
Expand Down
136 changes: 134 additions & 2 deletions violet-search/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@
전체 파이프라인 실행: OCR → print → summary
articles 디렉토리의 각 작품에 대해 결과가 없는 경우만 실행.
단계별로 전체 작품을 순차 처리.

OCR 단계는 모델 1회 로딩 + 전처리↔OCR 파이프라이닝으로 최적화.
"""

import os
import shutil
import subprocess
import sys
import tempfile
from concurrent.futures import ThreadPoolExecutor, as_completed

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
Expand All @@ -19,6 +23,8 @@

python = sys.executable

OCR_WORKERS = 5


def get_article_ids():
ids = []
Expand All @@ -36,6 +42,123 @@ def run(cmd: list[str]) -> bool:
return result.returncode == 0


def run_ocr_pipeline(pending_articles: list[tuple[str, str]], config: dict, workers: int):
"""
pending_articles: [(article_id, article_dir), ...]

1) OCR Pool 1회 생성 (GPU 모델 로딩 1회)
2) 전처리 스레드풀이 이미지 변환 → 완료 즉시 OCR Pool에 submit
3) OCR 완료 즉시 progress 출력, 결과를 article별로 수집 → JSON 저장
"""
import threading
from ocr_article import (
create_ocr_pool,
get_sorted_images,
convert_single,
_worker_process_image,
save_ocr_result,
)

pool = create_ocr_pool(workers, config)
tmp_dir = tempfile.mkdtemp()

try:
preprocess_pool = ThreadPoolExecutor(max_workers=8)

# article별 AsyncResult 목록 및 메타데이터
article_async_results: dict[str, list] = {}
article_metadata: dict[str, dict] = {}

# 전체 이미지 수 미리 집계 (progress 표시용)
articles_with_images = []
for article_id, article_dir in pending_articles:
images = get_sorted_images(article_dir)
if not images:
print(f" [스킵] {article_id} - 이미지 없음")
continue
articles_with_images.append((article_id, article_dir, images))

total_images = sum(len(imgs) for _, _, imgs in articles_with_images)
completed_count = [0]
count_lock = threading.Lock()

# 동시에 OCR 큐에 올라갈 수 있는 태스크 수 제한
# workers * 2: 각 워커가 처리 중 + 다음 태스크 1개 대기 → GPU idle 없이 연속 처리
# mobile 모델 기준 16GB VRAM에서 workers=5이면 ~1GB 미만이므로 여유 충분
ocr_semaphore = threading.Semaphore(workers * 2)

def make_ocr_callback(aid):
"""OCR 완료 시 즉시 progress를 출력하고 세마포어를 해제하는 콜백 생성"""
def on_ocr_done(result):
ocr_semaphore.release()
with count_lock:
completed_count[0] += 1
cnt = completed_count[0]
dlg_preview = " | ".join(d["text"][:20] for d in result["dialogues"][:3])
if len(result["dialogues"]) > 3:
dlg_preview += " ..."
print(
f" [{cnt}/{total_images}] {aid} p{result['page']}: "
f"{len(result['dialogues'])}개 대사"
+ (f" - {dlg_preview}" if dlg_preview else ""),
flush=True,
)
return on_ocr_done

for article_id, article_dir, images in articles_with_images:
article_async_results[article_id] = []
article_metadata[article_id] = {
"total_pages": len(images),
}

for page_num, img_path in images:
future = preprocess_pool.submit(convert_single, (img_path, tmp_dir))

def on_preprocess_done(fut, _aid=article_id, _pn=page_num):
ocr_semaphore.acquire() # OCR 큐가 꽉 찼으면 전처리 스레드 대기
try:
target, width, height = fut.result()
except Exception:
ocr_semaphore.release()
raise
task = {
"target": target,
"page_num": _pn,
"width": width,
"height": height,
}
async_result = pool.apply_async(
_worker_process_image, (task,),
callback=make_ocr_callback(_aid),
)
article_async_results[_aid].append(async_result)

future.add_done_callback(on_preprocess_done)

# 전처리 완료 대기 → 모든 OCR 태스크 제출 완료
preprocess_pool.shutdown(wait=True)

# OCR 결과 수집 및 저장 (이미 완료된 것은 즉시 반환)
for article_id, async_results in article_async_results.items():
pages = [ar.get() for ar in async_results]
pages.sort(key=lambda x: x["page"])
meta = article_metadata[article_id]

output = {
"articleId": article_id,
"totalPages": meta["total_pages"],
"threshold": config["threshold"],
"pages": pages,
}
output_path = save_ocr_result(output, RAW_DIR)
print(f" [저장] {article_id} ({len(pages)}페이지) -> {output_path}")

finally:
pool.close()
pool.join()
shutil.rmtree(tmp_dir, ignore_errors=True)


def main():
article_ids = get_article_ids()
print(f"총 {len(article_ids)}개 작품 발견")
Expand All @@ -45,14 +168,23 @@ def main():
print("=" * 50)
print("1단계: OCR")
print("=" * 50)

from ocr_article import make_ocr_config

config = make_ocr_config()

pending_articles = []
for article_id in article_ids:
raw_path = os.path.join(RAW_DIR, f"{article_id}.json")
if os.path.exists(raw_path):
print(f" [건너뜀] {article_id}")
continue
article_dir = os.path.join(ARTICLES_DIR, article_id)
if not run([python, os.path.join(SCRIPT_DIR, "ocr_article.py"), article_dir]):
print(f" [실패] {article_id}")
pending_articles.append((article_id, article_dir))

if pending_articles:
print(f" {len(pending_articles)}개 작품 OCR 실행")
run_ocr_pipeline(pending_articles, config, OCR_WORKERS)
print()

# ── 2단계: print ──
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const ARTICLES_DIR = path.resolve(__dirname, '../../data/articles');
const USER_AGENT =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';

const MAX_RETRIES = 10;
const MAX_RETRIES = 100;
const RETRY_DELAY_MS = 500;

async function fetchWithRetry(
Expand Down
40 changes: 40 additions & 0 deletions violet-web/packages/frontend/src/api/hot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export type HotPeriod = 'daily' | 'weekly' | 'monthly' | 'alltime';

interface HotViewResponse {
elements: { articleId: number; count: number }[];
}

async function computeVValid(salt: string, token: string): Promise<string> {
const input = salt.replace(/\\/g, '') + token;
const encoded = new TextEncoder().encode(input);
const hashBuffer = await crypto.subtle.digest('SHA-512', encoded);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
return hex.slice(0, 7);
}

export async function fetchHotView(
serverHost: string,
salt: string,
period: HotPeriod,
offset: number,
count: number,
): Promise<{ elements: { articleId: number; count: number }[] }> {
const token = Date.now().toString();
const valid = await computeVValid(salt, token);

const url = `${serverHost}/api/v2/view?offset=${offset}&count=${count}&type=${period}`;
const res = await fetch(url, {
headers: {
'v-token': token,
'v-valid': valid,
},
});

if (!res.ok) {
throw new Error(`Hot API error: ${res.status}`);
}

const data: HotViewResponse = await res.json();
return { elements: data.elements };
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ interface LazyImageProps {
alt?: string;
className?: string;
onClick?: () => void;
onLoad?: () => void;
}

export function LazyImage({ src, alt = '', className, onClick }: LazyImageProps) {
export function LazyImage({ src, alt = '', className, onClick, onLoad }: LazyImageProps) {
const [loaded, setLoaded] = useState(false);
const [inView, setInView] = useState(false);
const [error, setError] = useState(false);
Expand Down Expand Up @@ -44,7 +45,7 @@ export function LazyImage({ src, alt = '', className, onClick }: LazyImageProps)
src={src}
alt={alt}
className={`${styles.image} ${loaded ? styles.loaded : ''}`}
onLoad={() => setLoaded(true)}
onLoad={() => { setLoaded(true); onLoad?.(); }}
onError={() => setError(true)}
loading="lazy"
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import styles from './BottomNav.module.css';

const navItems = [
{ to: '/', labelKey: 'nav.home' },
{ to: '/hot', labelKey: 'nav.hot' },
{ to: '/history', labelKey: 'nav.history' },
{ to: '/bookmarks', labelKey: 'nav.bookmarks' },
{ to: '/crop-bookmarks', labelKey: 'nav.cropBookmarks' },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { NavLink, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { Home, Bookmark, Crop, History, Download, Settings, ChevronLeft, ChevronRight, Sparkles, Sun, Moon, Monitor } from 'lucide-react';
import { Home, Bookmark, Crop, History, Download, Settings, ChevronLeft, ChevronRight, Sparkles, Flame, Sun, Moon, Monitor } from 'lucide-react';
import { DiscordIcon } from '../icons/DiscordIcon';
import { GithubIcon } from '../icons/GithubIcon';
import { useAppStore } from '../../stores/app-store';
import styles from './Sidebar.module.css';

const navItems = [
{ to: '/', labelKey: 'nav.home', icon: Home },
{ to: '/hot', labelKey: 'nav.hot', icon: Flame },
{ to: '/history', labelKey: 'nav.history', icon: History },
{ to: '/bookmarks', labelKey: 'nav.bookmarks', icon: Bookmark },
{ to: '/crop-bookmarks', labelKey: 'nav.cropBookmarks', icon: Crop },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,20 @@
transform: scale(1.05);
}

.rankBadge {
position: absolute;
bottom: 6px;
left: 6px;
padding: 2px 7px;
background: rgba(0, 0, 0, 0.75);
color: #fff;
font-size: 0.7rem;
font-weight: 600;
border-radius: 4px;
z-index: 5;
letter-spacing: -0.01em;
}

.progressOverlay {
position: absolute;
inset: 0;
Expand Down
Loading