Make scanner output look like a real scan.
- Estimate the true paper aspect from the perspective of the four corners (Zhang & He) and snap to A-series/Letter, instead of stretching to the longest edges. - Keep native resolution (1754-3508px long side) and warp with cubic interpolation, trimming a thin border. - Replace the global contrast tweak with OpenCV scan filters: illumination flattening (background division) plus ink levels and sharpening for color/gray, adaptive threshold for B&W, and an original mode. - Export color pages as JPEG, size PDF pages to A4 and scale OCR text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c4ae7495dd
commit
2cb0c75092
4 files changed
+348
-107
No files matched your search
@@ -21,6 +21,14 @@ async function loadCjkFont(pdf: PDFDocument) {
|
|||||||
return pdf.embedFont(bytes, { subset: true });
|
return pdf.embedFont(bytes, { subset: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 스캔 페이지의 긴 변을 A4 긴 변(297mm = 841.89pt)으로 맞춘다 — 픽셀 수를 그대로 pt로 쓰면 종이가 수십 cm가 된다 */
|
||||||
|
const PAGE_LONG_PT = 841.89;
|
||||||
|
|
||||||
|
const isJpeg = (bytes: ArrayBuffer) => {
|
||||||
|
const b = new Uint8Array(bytes, 0, 2);
|
||||||
|
return b[0] === 0xff && b[1] === 0xd8;
|
||||||
|
};
|
||||||
|
|
||||||
export async function buildSearchablePdf(pages: PdfPageInput[]) {
|
export async function buildSearchablePdf(pages: PdfPageInput[]) {
|
||||||
if (!pages.length) {
|
if (!pages.length) {
|
||||||
throw new Error('PDF로 내보낼 페이지가 없습니다.');
|
throw new Error('PDF로 내보낼 페이지가 없습니다.');
|
||||||
@@ -42,13 +50,18 @@ export async function buildSearchablePdf(pages: PdfPageInput[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const page of pages) {
|
for (const page of pages) {
|
||||||
const image = await pdf.embedPng(page.imageBytes);
|
const image = isJpeg(page.imageBytes)
|
||||||
const pdfPage = pdf.addPage([page.width, page.height]);
|
? await pdf.embedJpg(page.imageBytes)
|
||||||
|
: await pdf.embedPng(page.imageBytes);
|
||||||
|
const k = PAGE_LONG_PT / Math.max(page.width, page.height);
|
||||||
|
const pageWidth = page.width * k;
|
||||||
|
const pageHeight = page.height * k;
|
||||||
|
const pdfPage = pdf.addPage([pageWidth, pageHeight]);
|
||||||
pdfPage.drawImage(image, {
|
pdfPage.drawImage(image, {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
width: page.width,
|
width: pageWidth,
|
||||||
height: page.height,
|
height: pageHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
const font = cjkFont ?? latinFont;
|
const font = cjkFont ?? latinFont;
|
||||||
@@ -57,11 +70,12 @@ export async function buildSearchablePdf(pages: PdfPageInput[]) {
|
|||||||
const text = word.text.trim();
|
const text = word.text.trim();
|
||||||
if (!text) continue;
|
if (!text) continue;
|
||||||
if (!cjkFont && /[^\u0000-\u007f]/.test(text)) continue;
|
if (!cjkFont && /[^\u0000-\u007f]/.test(text)) continue;
|
||||||
const boxWidth = Math.max(1, word.bbox.x1 - word.bbox.x0);
|
// OCR 좌표는 스캔 이미지 픽셀 기준 → pt로 환산
|
||||||
const boxHeight = Math.max(1, word.bbox.y1 - word.bbox.y0);
|
const boxWidth = Math.max(1, (word.bbox.x1 - word.bbox.x0) * k);
|
||||||
const fontSize = Math.max(4, Math.min(boxHeight * 0.9, boxWidth));
|
const boxHeight = Math.max(1, (word.bbox.y1 - word.bbox.y0) * k);
|
||||||
const x = word.bbox.x0;
|
const fontSize = Math.max(2, Math.min(boxHeight * 0.9, boxWidth));
|
||||||
const y = page.height - word.bbox.y1;
|
const x = word.bbox.x0 * k;
|
||||||
|
const y = pageHeight - word.bbox.y1 * k;
|
||||||
try {
|
try {
|
||||||
pdfPage.drawText(text, {
|
pdfPage.drawText(text, {
|
||||||
x,
|
x,
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export interface CornerPoints {
|
|||||||
|
|
||||||
export type PaperAspect = 'a4-portrait' | 'a4-landscape' | 'square' | 'auto';
|
export type PaperAspect = 'a4-portrait' | 'a4-landscape' | 'square' | 'auto';
|
||||||
|
|
||||||
|
/** 원본: 원근 보정만 · 컬러/회색조 문서: 조명 평탄화·글자 보정 · 흑백 문서: 이진화 */
|
||||||
|
export type ScanMode = 'original' | 'color' | 'gray' | 'bw';
|
||||||
|
|
||||||
export interface OcrWord {
|
export interface OcrWord {
|
||||||
text: string;
|
text: string;
|
||||||
/** Pixel bbox on the warped image: x0,y0,x1,y1 */
|
/** Pixel bbox on the warped image: x0,y0,x1,y1 */
|
||||||
@@ -32,7 +35,8 @@ export interface ScannerPage {
|
|||||||
sourceHeight: number;
|
sourceHeight: number;
|
||||||
corners: CornerPoints;
|
corners: CornerPoints;
|
||||||
aspect: PaperAspect;
|
aspect: PaperAspect;
|
||||||
/** 0 = 원본 색감, 100 = 강한 문서 스캔 보정 */
|
scanMode: ScanMode;
|
||||||
|
/** 글자 진하기·선명도 0–100 (흑백은 이진화 문턱) */
|
||||||
scanIntensity: number;
|
scanIntensity: number;
|
||||||
warpedUrl: string | null;
|
warpedUrl: string | null;
|
||||||
warpedWidth: number;
|
warpedWidth: number;
|
||||||
|
|||||||
@@ -1,103 +1,251 @@
|
|||||||
import {
|
import { ensureOpenCv, loadImageFromUrl } from './detect';
|
||||||
createScanner,
|
import type { CornerPoints, PaperAspect, ScanMode } from './types';
|
||||||
ensureJscanify,
|
|
||||||
loadImageFromUrl,
|
|
||||||
} from './detect';
|
|
||||||
import type { CornerPoints, PaperAspect } from './types';
|
|
||||||
|
|
||||||
export const DEFAULT_SCAN_INTENSITY = 55;
|
/** 0 = 은은하게 · 100 = 글자를 아주 진하고 선명하게 (조명 평탄화는 강도와 무관하게 항상) */
|
||||||
|
export const DEFAULT_SCAN_INTENSITY = 60;
|
||||||
|
export const DEFAULT_SCAN_MODE: ScanMode = 'color';
|
||||||
|
|
||||||
function clampByte(value: number) {
|
const A_RATIO = Math.SQRT2; // A 계열 용지 (A4 등)
|
||||||
return Math.min(255, Math.max(0, Math.round(value)));
|
const LETTER_RATIO = 11 / 8.5;
|
||||||
|
/** 원본이 이보다 크면 줄여서 처리 (48MP 사진 등에서 WASM 메모리 부족 방지) */
|
||||||
|
const MAX_SOURCE_SIDE = 4096;
|
||||||
|
|
||||||
|
// jscanify에 들어 있는 OpenCV.js(4.7)에는 타입 정의가 없다
|
||||||
|
type Cv = any;
|
||||||
|
type Mat = any;
|
||||||
|
|
||||||
|
const dist = (a: { x: number; y: number }, b: { x: number; y: number }) =>
|
||||||
|
Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
const odd = (n: number) => Math.round(n) | 1;
|
||||||
|
|
||||||
|
function edgeAspect(c: CornerPoints) {
|
||||||
|
const w =
|
||||||
|
(dist(c.topLeftCorner, c.topRightCorner) +
|
||||||
|
dist(c.bottomLeftCorner, c.bottomRightCorner)) /
|
||||||
|
2;
|
||||||
|
const h =
|
||||||
|
(dist(c.topLeftCorner, c.bottomLeftCorner) +
|
||||||
|
dist(c.topRightCorner, c.bottomRightCorner)) /
|
||||||
|
2;
|
||||||
|
return w / Math.max(h, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 스캔 강도(0–100): 대비·배경 미백·문서형 흑백 혼합 */
|
/**
|
||||||
export function applyScanIntensity(
|
* 원근을 고려한 실제 용지 가로/세로 비.
|
||||||
canvas: HTMLCanvasElement,
|
* 비스듬히 찍으면 먼 쪽 변이 짧아져 변 길이만으로는 비율이 크게 틀어진다(늘어난 듯한 결과).
|
||||||
intensity: number,
|
* Zhang & He, "Whiteboard scanning and image enhancement"(2007) — 주점은 사진 중앙으로 가정.
|
||||||
|
* 정면에 가깝거나 값이 불안정하면 변 길이 평균으로 대신한다.
|
||||||
|
*/
|
||||||
|
export function estimateAspect(
|
||||||
|
c: CornerPoints,
|
||||||
|
imageWidth: number,
|
||||||
|
imageHeight: number,
|
||||||
) {
|
) {
|
||||||
const amount = Math.min(100, Math.max(0, intensity)) / 100;
|
type V3 = [number, number, number];
|
||||||
if (amount <= 0.001) return canvas;
|
const u0 = imageWidth / 2;
|
||||||
|
const v0 = imageHeight / 2;
|
||||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
const p = (pt: { x: number; y: number }): V3 => [pt.x - u0, pt.y - v0, 1];
|
||||||
if (!ctx) return canvas;
|
const cross = (a: V3, b: V3): V3 => [
|
||||||
|
a[1] * b[2] - a[2] * b[1],
|
||||||
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
a[2] * b[0] - a[0] * b[2],
|
||||||
const { data } = image;
|
a[0] * b[1] - a[1] * b[0],
|
||||||
const contrast = 1 + amount * 1.45;
|
];
|
||||||
const grayMix = amount * 0.9;
|
const dot = (a: V3, b: V3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||||
const whitePush = amount * 0.42;
|
const m1 = p(c.topLeftCorner);
|
||||||
const inkMix = Math.max(0, (amount - 0.55) / 0.45);
|
const m2 = p(c.topRightCorner);
|
||||||
|
const m3 = p(c.bottomLeftCorner);
|
||||||
for (let i = 0; i < data.length; i += 4) {
|
const m4 = p(c.bottomRightCorner);
|
||||||
let r = data[i]!;
|
const c14 = cross(m1, m4);
|
||||||
let g = data[i + 1]!;
|
const k2 = dot(c14, m3) / dot(cross(m2, m4), m3);
|
||||||
let b = data[i + 2]!;
|
const k3 = dot(c14, m2) / dot(cross(m3, m4), m2);
|
||||||
const y = 0.299 * r + 0.587 * g + 0.114 * b;
|
const n2 = m2.map((v, i) => k2 * v - m1[i]!) as V3;
|
||||||
r = r * (1 - grayMix) + y * grayMix;
|
const n3 = m3.map((v, i) => k3 * v - m1[i]!) as V3;
|
||||||
g = g * (1 - grayMix) + y * grayMix;
|
const denom = n2[2] * n3[2];
|
||||||
b = b * (1 - grayMix) + y * grayMix;
|
if (!Number.isFinite(k2) || !Number.isFinite(k3) || Math.abs(denom) < 1e-9) {
|
||||||
|
return edgeAspect(c);
|
||||||
r = (r - 128) * contrast + 128;
|
}
|
||||||
g = (g - 128) * contrast + 128;
|
const f2 = -(n2[0] * n3[0] + n2[1] * n3[1]) / denom;
|
||||||
b = (b - 128) * contrast + 128;
|
const maxDim = Math.max(imageWidth, imageHeight);
|
||||||
|
if (!(f2 > 0) || Math.sqrt(f2) < maxDim * 0.3 || Math.sqrt(f2) > maxDim * 6) {
|
||||||
if (whitePush > 0) {
|
return edgeAspect(c);
|
||||||
const lift = (v: number) =>
|
}
|
||||||
v > 155 ? v + (255 - v) * whitePush * ((v - 155) / 100) : v;
|
const ratio = Math.sqrt(
|
||||||
r = lift(r);
|
(n2[0] ** 2 + n2[1] ** 2 + n2[2] ** 2 * f2) /
|
||||||
g = lift(g);
|
(n3[0] ** 2 + n3[1] ** 2 + n3[2] ** 2 * f2),
|
||||||
b = lift(b);
|
);
|
||||||
|
return Number.isFinite(ratio) && ratio > 0.15 && ratio < 7
|
||||||
|
? ratio
|
||||||
|
: edgeAspect(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inkMix > 0) {
|
/** 흔한 용지 비율(A 계열, Letter)과 4% 안쪽이면 그 비율로 맞춘다 */
|
||||||
const y2 = 0.299 * r + 0.587 * g + 0.114 * b;
|
function snapAspect(ratio: number) {
|
||||||
const ink = Math.max(0, Math.min(1, (175 - y2) / 85));
|
for (const target of [A_RATIO, LETTER_RATIO]) {
|
||||||
const doc = 255 * (1 - ink) + 18 * ink;
|
for (const t of [target, 1 / target]) {
|
||||||
r = r * (1 - inkMix) + doc * inkMix;
|
if (Math.abs(ratio / t - 1) < 0.04) return t;
|
||||||
g = g * (1 - inkMix) + doc * inkMix;
|
|
||||||
b = b * (1 - inkMix) + doc * inkMix;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data[i] = clampByte(r);
|
|
||||||
data[i + 1] = clampByte(g);
|
|
||||||
data[i + 2] = clampByte(b);
|
|
||||||
}
|
}
|
||||||
|
return ratio;
|
||||||
ctx.putImageData(image, 0, 0);
|
|
||||||
return canvas;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 결과 크기: 비율은 용지 설정(자동이면 원근 추정), 긴 변은 원본에서 문서가 차지한 픽셀 수 기준
|
||||||
|
* (1754–3508px = A4 150–300dpi, 원본보다 크게 늘리지 않음) */
|
||||||
export function outputSizeForAspect(
|
export function outputSizeForAspect(
|
||||||
aspect: PaperAspect,
|
aspect: PaperAspect,
|
||||||
corners: CornerPoints,
|
corners: CornerPoints,
|
||||||
|
imageWidth: number,
|
||||||
|
imageHeight: number,
|
||||||
) {
|
) {
|
||||||
if (aspect === 'a4-portrait') return { width: 1240, height: 1754 };
|
let ratio: number;
|
||||||
if (aspect === 'a4-landscape') return { width: 1754, height: 1240 };
|
if (aspect === 'a4-portrait') ratio = 1 / A_RATIO;
|
||||||
if (aspect === 'square') return { width: 1400, height: 1400 };
|
else if (aspect === 'a4-landscape') ratio = A_RATIO;
|
||||||
|
else if (aspect === 'square') ratio = 1;
|
||||||
|
else ratio = snapAspect(estimateAspect(corners, imageWidth, imageHeight));
|
||||||
|
const sourceLong = Math.max(
|
||||||
|
dist(corners.topLeftCorner, corners.topRightCorner),
|
||||||
|
dist(corners.bottomLeftCorner, corners.bottomRightCorner),
|
||||||
|
dist(corners.topLeftCorner, corners.bottomLeftCorner),
|
||||||
|
dist(corners.topRightCorner, corners.bottomRightCorner),
|
||||||
|
);
|
||||||
|
const long = Math.round(Math.min(3508, Math.max(1754, sourceLong * 1.1)));
|
||||||
|
return ratio >= 1
|
||||||
|
? { width: long, height: Math.max(1, Math.round(long / ratio)) }
|
||||||
|
: { width: Math.max(1, Math.round(long * ratio)), height: long };
|
||||||
|
}
|
||||||
|
|
||||||
const widthTop = Math.hypot(
|
/** 원근 보정 (RGB → RGB). 가장자리는 늘려 채워 검은 테두리가 생기지 않게 한다 */
|
||||||
corners.topRightCorner.x - corners.topLeftCorner.x,
|
function warpMat(
|
||||||
corners.topRightCorner.y - corners.topLeftCorner.y,
|
cv: Cv,
|
||||||
|
src: Mat,
|
||||||
|
c: CornerPoints,
|
||||||
|
size: { width: number; height: number },
|
||||||
|
) {
|
||||||
|
const from = cv.matFromArray(4, 1, cv.CV_32FC2, [
|
||||||
|
c.topLeftCorner.x, c.topLeftCorner.y,
|
||||||
|
c.topRightCorner.x, c.topRightCorner.y,
|
||||||
|
c.bottomRightCorner.x, c.bottomRightCorner.y,
|
||||||
|
c.bottomLeftCorner.x, c.bottomLeftCorner.y,
|
||||||
|
]);
|
||||||
|
// 가장자리를 0.4% 안쪽으로 잘라 코너 오차로 섞여 든 책상·그림자 테두리를 없앤다
|
||||||
|
const m = Math.round(Math.max(size.width, size.height) * 0.004);
|
||||||
|
const to = cv.matFromArray(4, 1, cv.CV_32FC2, [
|
||||||
|
-m, -m, size.width + m, -m, size.width + m, size.height + m, -m, size.height + m,
|
||||||
|
]);
|
||||||
|
const transform = cv.getPerspectiveTransform(from, to);
|
||||||
|
const out = new cv.Mat();
|
||||||
|
cv.warpPerspective(
|
||||||
|
src, out, transform, new cv.Size(size.width, size.height),
|
||||||
|
cv.INTER_CUBIC, cv.BORDER_REPLICATE,
|
||||||
);
|
);
|
||||||
const widthBottom = Math.hypot(
|
from.delete();
|
||||||
corners.bottomRightCorner.x - corners.bottomLeftCorner.x,
|
to.delete();
|
||||||
corners.bottomRightCorner.y - corners.bottomLeftCorner.y,
|
transform.delete();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조명 평탄화: 글자를 지운 종이 배경(밝은 쪽 팽창 + 메디안 블러)을 추정해 원본을 나눈다.
|
||||||
|
* 그림자·밝기 차이·누런 조명이 사라지고 종이는 흰색, 도장·서명 같은 색은 남는다.
|
||||||
|
*/
|
||||||
|
function flatten(cv: Cv, src: Mat) {
|
||||||
|
const scale = Math.min(1, 640 / Math.max(src.cols, src.rows));
|
||||||
|
const small = new cv.Mat();
|
||||||
|
cv.resize(
|
||||||
|
src, small,
|
||||||
|
new cv.Size(Math.round(src.cols * scale), Math.round(src.rows * scale)),
|
||||||
|
0, 0, cv.INTER_AREA,
|
||||||
);
|
);
|
||||||
const heightLeft = Math.hypot(
|
const kernel = cv.getStructuringElement(cv.MORPH_ELLIPSE, new cv.Size(9, 9));
|
||||||
corners.bottomLeftCorner.x - corners.topLeftCorner.x,
|
cv.dilate(small, small, kernel);
|
||||||
corners.bottomLeftCorner.y - corners.topLeftCorner.y,
|
cv.medianBlur(small, small, 31);
|
||||||
|
cv.GaussianBlur(small, small, new cv.Size(0, 0), 3);
|
||||||
|
const background = new cv.Mat();
|
||||||
|
cv.resize(small, background, new cv.Size(src.cols, src.rows), 0, 0, cv.INTER_LINEAR);
|
||||||
|
const out = new cv.Mat();
|
||||||
|
cv.divide(src, background, out, 255);
|
||||||
|
small.delete();
|
||||||
|
kernel.delete();
|
||||||
|
background.delete();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 레벨 보정: 잉크 쪽 하위 blackPct를 0으로 당기고 감마로 글자를 진하게 (이 빌드에는 cv.LUT가 없다) */
|
||||||
|
function levels(mat: Mat, blackPct: number, gamma: number, whiteAt = 245) {
|
||||||
|
const data = mat.data as Uint8Array;
|
||||||
|
const hist = new Uint32Array(256);
|
||||||
|
for (let i = 0; i < data.length; i += 1) hist[data[i]!] += 1;
|
||||||
|
const target = data.length * blackPct;
|
||||||
|
let acc = 0;
|
||||||
|
let black = 0;
|
||||||
|
for (let v = 0; v < 256; v += 1) {
|
||||||
|
acc += hist[v]!;
|
||||||
|
if (acc >= target) {
|
||||||
|
black = v;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
black = Math.min(black, whiteAt - 40);
|
||||||
|
const lut = new Uint8Array(256);
|
||||||
|
for (let v = 0; v < 256; v += 1) {
|
||||||
|
const t = Math.min(1, Math.max(0, (v - black) / (whiteAt - black)));
|
||||||
|
lut[v] = Math.round(255 * t ** gamma);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < data.length; i += 1) data[i] = lut[data[i]!]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sharpen(cv: Cv, mat: Mat, amount: number) {
|
||||||
|
const blur = new cv.Mat();
|
||||||
|
cv.GaussianBlur(mat, blur, new cv.Size(0, 0), 1.1);
|
||||||
|
cv.addWeighted(mat, 1 + amount, blur, -amount, 0, mat);
|
||||||
|
blur.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 스캔 필터. 원본: 원근 보정만 · 컬러/회색조: 조명 평탄화 + 글자 진하게 + 선명 · 흑백: 적응형 이진화 */
|
||||||
|
function enhance(cv: Cv, src: Mat, mode: ScanMode, intensity: number) {
|
||||||
|
const a = Math.min(100, Math.max(0, intensity)) / 100;
|
||||||
|
if (mode === 'original') return src.clone();
|
||||||
|
let base = src;
|
||||||
|
if (mode === 'gray' || mode === 'bw') {
|
||||||
|
base = new cv.Mat();
|
||||||
|
cv.cvtColor(src, base, cv.COLOR_RGB2GRAY);
|
||||||
|
}
|
||||||
|
const flat = flatten(cv, base);
|
||||||
|
if (base !== src) base.delete();
|
||||||
|
if (mode === 'bw') {
|
||||||
|
const out = new cv.Mat();
|
||||||
|
cv.adaptiveThreshold(
|
||||||
|
flat, out, 255, cv.ADAPTIVE_THRESH_GAUSSIAN_C, cv.THRESH_BINARY,
|
||||||
|
odd(Math.max(flat.cols, flat.rows) / 70), 6 + 14 * a,
|
||||||
);
|
);
|
||||||
const heightRight = Math.hypot(
|
flat.delete();
|
||||||
corners.bottomRightCorner.x - corners.topRightCorner.x,
|
return out;
|
||||||
corners.bottomRightCorner.y - corners.topRightCorner.y,
|
}
|
||||||
);
|
levels(flat, 0.002 + 0.02 * a, 1 + 0.6 * a);
|
||||||
const width = Math.max(widthTop, widthBottom, 1);
|
sharpen(cv, flat, 0.3 + 0.5 * a);
|
||||||
const height = Math.max(heightLeft, heightRight, 1);
|
return flat;
|
||||||
const scale = Math.min(1800 / width, 1800 / height, 1);
|
}
|
||||||
|
|
||||||
|
/** 원본을 캔버스에 그린다. 너무 크면 줄이고 그 배율을 돌려준다 */
|
||||||
|
function sourceCanvas(image: HTMLImageElement) {
|
||||||
|
const w = image.naturalWidth || image.width;
|
||||||
|
const h = image.naturalHeight || image.height;
|
||||||
|
const scale = Math.min(1, MAX_SOURCE_SIDE / Math.max(w, h));
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.max(1, Math.round(w * scale));
|
||||||
|
canvas.height = Math.max(1, Math.round(h * scale));
|
||||||
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
if (!ctx) throw new Error('Canvas를 초기화하지 못했습니다.');
|
||||||
|
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||||
|
return { canvas, scale };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scaleCorners(c: CornerPoints, s: number): CornerPoints {
|
||||||
|
const f = (p: { x: number; y: number }) => ({ x: p.x * s, y: p.y * s });
|
||||||
return {
|
return {
|
||||||
width: Math.max(1, Math.round(width * scale)),
|
topLeftCorner: f(c.topLeftCorner),
|
||||||
height: Math.max(1, Math.round(height * scale)),
|
topRightCorner: f(c.topRightCorner),
|
||||||
|
bottomRightCorner: f(c.bottomRightCorner),
|
||||||
|
bottomLeftCorner: f(c.bottomLeftCorner),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,28 +254,46 @@ export async function warpImage(
|
|||||||
corners: CornerPoints,
|
corners: CornerPoints,
|
||||||
aspect: PaperAspect,
|
aspect: PaperAspect,
|
||||||
scanIntensity: number = DEFAULT_SCAN_INTENSITY,
|
scanIntensity: number = DEFAULT_SCAN_INTENSITY,
|
||||||
|
scanMode: ScanMode = DEFAULT_SCAN_MODE,
|
||||||
) {
|
) {
|
||||||
await ensureJscanify();
|
await ensureOpenCv();
|
||||||
|
const cv: Cv = window.cv;
|
||||||
const image = await loadImageFromUrl(sourceUrl);
|
const image = await loadImageFromUrl(sourceUrl);
|
||||||
const size = outputSizeForAspect(aspect, corners);
|
const { canvas: source, scale } = sourceCanvas(image);
|
||||||
const scanner = createScanner();
|
const c = scaleCorners(corners, scale);
|
||||||
const canvas = scanner.extractPaper(
|
const size = outputSizeForAspect(aspect, c, source.width, source.height);
|
||||||
image,
|
|
||||||
size.width,
|
const rgba = cv.imread(source);
|
||||||
size.height,
|
const rgb = new cv.Mat();
|
||||||
corners,
|
cv.cvtColor(rgba, rgb, cv.COLOR_RGBA2RGB);
|
||||||
|
rgba.delete();
|
||||||
|
let warped: Mat = null;
|
||||||
|
let result: Mat = null;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
try {
|
||||||
|
warped = warpMat(cv, rgb, c, size);
|
||||||
|
result = enhance(cv, warped, scanMode, scanIntensity);
|
||||||
|
cv.imshow(canvas, result);
|
||||||
|
} catch (reason) {
|
||||||
|
throw new Error(
|
||||||
|
`문서 보정에 실패했습니다: ${reason instanceof Error ? reason.message : String(reason)}`,
|
||||||
);
|
);
|
||||||
if (!canvas) {
|
} finally {
|
||||||
throw new Error('문서 영역을 추출하지 못했습니다.');
|
rgb.delete();
|
||||||
|
warped?.delete();
|
||||||
|
result?.delete();
|
||||||
}
|
}
|
||||||
applyScanIntensity(canvas, scanIntensity);
|
|
||||||
|
// 흑백은 PNG가 작고 선명하다. 컬러·회색조는 JPEG(PNG면 페이지당 10MB 이상)
|
||||||
|
const type = scanMode === 'bw' ? 'image/png' : 'image/jpeg';
|
||||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
(value) =>
|
(value) =>
|
||||||
value
|
value
|
||||||
? resolve(value)
|
? resolve(value)
|
||||||
: reject(new Error('보정 이미지를 만들지 못했습니다.')),
|
: reject(new Error('보정 이미지를 만들지 못했습니다.')),
|
||||||
'image/png',
|
type,
|
||||||
|
0.92,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -29,9 +29,14 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
CornerPoints,
|
CornerPoints,
|
||||||
PaperAspect,
|
PaperAspect,
|
||||||
|
ScanMode,
|
||||||
ScannerPage,
|
ScannerPage,
|
||||||
} from '@/integrations/scanner/types';
|
} from '@/integrations/scanner/types';
|
||||||
import { warpImage, DEFAULT_SCAN_INTENSITY } from '@/integrations/scanner/warp';
|
import {
|
||||||
|
warpImage,
|
||||||
|
DEFAULT_SCAN_INTENSITY,
|
||||||
|
DEFAULT_SCAN_MODE,
|
||||||
|
} from '@/integrations/scanner/warp';
|
||||||
import { downloadBlob } from '@/utils/downloadBlob';
|
import { downloadBlob } from '@/utils/downloadBlob';
|
||||||
|
|
||||||
defineOptions({ name: 'ToolsScannerView' });
|
defineOptions({ name: 'ToolsScannerView' });
|
||||||
@@ -125,6 +130,7 @@ async function pushPageFromSource(
|
|||||||
sourceHeight: h,
|
sourceHeight: h,
|
||||||
corners,
|
corners,
|
||||||
aspect: 'auto',
|
aspect: 'auto',
|
||||||
|
scanMode: DEFAULT_SCAN_MODE,
|
||||||
scanIntensity: DEFAULT_SCAN_INTENSITY,
|
scanIntensity: DEFAULT_SCAN_INTENSITY,
|
||||||
warpedUrl: null,
|
warpedUrl: null,
|
||||||
warpedWidth: 0,
|
warpedWidth: 0,
|
||||||
@@ -438,6 +444,19 @@ function setScanIntensity(value: number) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SCAN_MODES: { value: ScanMode; label: string }[] = [
|
||||||
|
{ value: 'color', label: '컬러 문서' },
|
||||||
|
{ value: 'gray', label: '회색조 문서' },
|
||||||
|
{ value: 'bw', label: '흑백 문서' },
|
||||||
|
{ value: 'original', label: '원본 (원근만)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function updateScanMode(mode: ScanMode) {
|
||||||
|
if (!activePage.value || busy.value) return;
|
||||||
|
activePage.value.scanMode = mode;
|
||||||
|
await warpActive();
|
||||||
|
}
|
||||||
|
|
||||||
async function commitScanIntensity() {
|
async function commitScanIntensity() {
|
||||||
if (!activePage.value || busy.value) return;
|
if (!activePage.value || busy.value) return;
|
||||||
await warpActive();
|
await warpActive();
|
||||||
@@ -482,6 +501,7 @@ async function warpActive() {
|
|||||||
activePage.value.corners,
|
activePage.value.corners,
|
||||||
activePage.value.aspect,
|
activePage.value.aspect,
|
||||||
activePage.value.scanIntensity,
|
activePage.value.scanIntensity,
|
||||||
|
activePage.value.scanMode,
|
||||||
);
|
);
|
||||||
revokeUrl(activePage.value.warpedUrl);
|
revokeUrl(activePage.value.warpedUrl);
|
||||||
activePage.value.warpedUrl = result.url;
|
activePage.value.warpedUrl = result.url;
|
||||||
@@ -511,6 +531,7 @@ async function warpAll() {
|
|||||||
page.corners,
|
page.corners,
|
||||||
page.aspect,
|
page.aspect,
|
||||||
page.scanIntensity,
|
page.scanIntensity,
|
||||||
|
page.scanMode,
|
||||||
);
|
);
|
||||||
revokeUrl(page.warpedUrl);
|
revokeUrl(page.warpedUrl);
|
||||||
page.warpedUrl = result.url;
|
page.warpedUrl = result.url;
|
||||||
@@ -536,6 +557,7 @@ async function runOcrOnPage(page: ScannerPage) {
|
|||||||
page.corners,
|
page.corners,
|
||||||
page.aspect,
|
page.aspect,
|
||||||
page.scanIntensity,
|
page.scanIntensity,
|
||||||
|
page.scanMode,
|
||||||
);
|
);
|
||||||
revokeUrl(page.warpedUrl);
|
revokeUrl(page.warpedUrl);
|
||||||
page.warpedUrl = result.url;
|
page.warpedUrl = result.url;
|
||||||
@@ -605,6 +627,7 @@ async function exportPdf() {
|
|||||||
page.corners,
|
page.corners,
|
||||||
page.aspect,
|
page.aspect,
|
||||||
page.scanIntensity,
|
page.scanIntensity,
|
||||||
|
page.scanMode,
|
||||||
);
|
);
|
||||||
revokeUrl(page.warpedUrl);
|
revokeUrl(page.warpedUrl);
|
||||||
page.warpedUrl = warped.url;
|
page.warpedUrl = warped.url;
|
||||||
@@ -645,7 +668,9 @@ async function exportActivePng() {
|
|||||||
if (!activePage.value.warpedUrl) await warpActive();
|
if (!activePage.value.warpedUrl) await warpActive();
|
||||||
if (!activePage.value?.warpedUrl) return;
|
if (!activePage.value?.warpedUrl) return;
|
||||||
const response = await fetch(activePage.value.warpedUrl);
|
const response = await fetch(activePage.value.warpedUrl);
|
||||||
downloadBlob(await response.blob(), `${baseName(activePage.value.name)}.png`);
|
const blob = await response.blob();
|
||||||
|
const ext = blob.type === 'image/jpeg' ? 'jpg' : 'png';
|
||||||
|
downloadBlob(blob, `${baseName(activePage.value.name)}.${ext}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function baseName(name = filename.value) {
|
function baseName(name = filename.value) {
|
||||||
@@ -954,7 +979,7 @@ const progressPercent = computed(() =>
|
|||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
@click="void exportActivePng()"
|
@click="void exportActivePng()"
|
||||||
>
|
>
|
||||||
PNG로 저장
|
이미지로 저장
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -984,6 +1009,22 @@ const progressPercent = computed(() =>
|
|||||||
<option value="auto">자동 비율</option>
|
<option value="auto">자동 비율</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="settings-field settings-field--inline">
|
||||||
|
<span>필터</span>
|
||||||
|
<select
|
||||||
|
:value="activePage.scanMode"
|
||||||
|
:disabled="busy"
|
||||||
|
@change="
|
||||||
|
void updateScanMode(
|
||||||
|
($event.target as HTMLSelectElement).value as ScanMode,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<option v-for="m in SCAN_MODES" :key="m.value" :value="m.value">
|
||||||
|
{{ m.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label class="settings-field settings-field--intensity">
|
<label class="settings-field settings-field--intensity">
|
||||||
<span class="settings-field__row">
|
<span class="settings-field__row">
|
||||||
스캔 강도
|
스캔 강도
|
||||||
@@ -1052,6 +1093,22 @@ const progressPercent = computed(() =>
|
|||||||
<option value="auto">자동 비율</option>
|
<option value="auto">자동 비율</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="settings-field">
|
||||||
|
<span>필터</span>
|
||||||
|
<select
|
||||||
|
:value="activePage.scanMode"
|
||||||
|
:disabled="busy"
|
||||||
|
@change="
|
||||||
|
void updateScanMode(
|
||||||
|
($event.target as HTMLSelectElement).value as ScanMode,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<option v-for="m in SCAN_MODES" :key="m.value" :value="m.value">
|
||||||
|
{{ m.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label class="settings-field">
|
<label class="settings-field">
|
||||||
<span class="settings-field__row">
|
<span class="settings-field__row">
|
||||||
스캔 강도
|
스캔 강도
|
||||||
@@ -1073,7 +1130,7 @@ const progressPercent = computed(() =>
|
|||||||
@change="void commitScanIntensity()"
|
@change="void commitScanIntensity()"
|
||||||
/>
|
/>
|
||||||
<span class="settings-field__hint">
|
<span class="settings-field__hint">
|
||||||
0 원본 · 중간 대비 · 100 문서형
|
0 은은하게 · 100 글자 진하고 선명하게
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="settings-check">
|
<label class="settings-check">
|
||||||
@@ -1105,7 +1162,7 @@ const progressPercent = computed(() =>
|
|||||||
void exportActivePng();
|
void exportActivePng();
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
PNG로 저장
|
이미지로 저장
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user