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
+353
-112
No files matched your search
@@ -21,6 +21,14 @@ async function loadCjkFont(pdf: PDFDocument) {
|
||||
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[]) {
|
||||
if (!pages.length) {
|
||||
throw new Error('PDF로 내보낼 페이지가 없습니다.');
|
||||
@@ -42,13 +50,18 @@ export async function buildSearchablePdf(pages: PdfPageInput[]) {
|
||||
}
|
||||
|
||||
for (const page of pages) {
|
||||
const image = await pdf.embedPng(page.imageBytes);
|
||||
const pdfPage = pdf.addPage([page.width, page.height]);
|
||||
const image = isJpeg(page.imageBytes)
|
||||
? 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, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: page.width,
|
||||
height: page.height,
|
||||
width: pageWidth,
|
||||
height: pageHeight,
|
||||
});
|
||||
|
||||
const font = cjkFont ?? latinFont;
|
||||
@@ -57,11 +70,12 @@ export async function buildSearchablePdf(pages: PdfPageInput[]) {
|
||||
const text = word.text.trim();
|
||||
if (!text) continue;
|
||||
if (!cjkFont && /[^\u0000-\u007f]/.test(text)) continue;
|
||||
const boxWidth = Math.max(1, word.bbox.x1 - word.bbox.x0);
|
||||
const boxHeight = Math.max(1, word.bbox.y1 - word.bbox.y0);
|
||||
const fontSize = Math.max(4, Math.min(boxHeight * 0.9, boxWidth));
|
||||
const x = word.bbox.x0;
|
||||
const y = page.height - word.bbox.y1;
|
||||
// OCR 좌표는 스캔 이미지 픽셀 기준 → pt로 환산
|
||||
const boxWidth = Math.max(1, (word.bbox.x1 - word.bbox.x0) * k);
|
||||
const boxHeight = Math.max(1, (word.bbox.y1 - word.bbox.y0) * k);
|
||||
const fontSize = Math.max(2, Math.min(boxHeight * 0.9, boxWidth));
|
||||
const x = word.bbox.x0 * k;
|
||||
const y = pageHeight - word.bbox.y1 * k;
|
||||
try {
|
||||
pdfPage.drawText(text, {
|
||||
x,
|
||||
|
||||
@@ -12,6 +12,9 @@ export interface CornerPoints {
|
||||
|
||||
export type PaperAspect = 'a4-portrait' | 'a4-landscape' | 'square' | 'auto';
|
||||
|
||||
/** 원본: 원근 보정만 · 컬러/회색조 문서: 조명 평탄화·글자 보정 · 흑백 문서: 이진화 */
|
||||
export type ScanMode = 'original' | 'color' | 'gray' | 'bw';
|
||||
|
||||
export interface OcrWord {
|
||||
text: string;
|
||||
/** Pixel bbox on the warped image: x0,y0,x1,y1 */
|
||||
@@ -32,7 +35,8 @@ export interface ScannerPage {
|
||||
sourceHeight: number;
|
||||
corners: CornerPoints;
|
||||
aspect: PaperAspect;
|
||||
/** 0 = 원본 색감, 100 = 강한 문서 스캔 보정 */
|
||||
scanMode: ScanMode;
|
||||
/** 글자 진하기·선명도 0–100 (흑백은 이진화 문턱) */
|
||||
scanIntensity: number;
|
||||
warpedUrl: string | null;
|
||||
warpedWidth: number;
|
||||
|
||||
@@ -1,103 +1,251 @@
|
||||
import {
|
||||
createScanner,
|
||||
ensureJscanify,
|
||||
loadImageFromUrl,
|
||||
} from './detect';
|
||||
import type { CornerPoints, PaperAspect } from './types';
|
||||
import { ensureOpenCv, loadImageFromUrl } from './detect';
|
||||
import type { CornerPoints, PaperAspect, ScanMode } 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) {
|
||||
return Math.min(255, Math.max(0, Math.round(value)));
|
||||
const A_RATIO = Math.SQRT2; // A 계열 용지 (A4 등)
|
||||
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;
|
||||
if (amount <= 0.001) return canvas;
|
||||
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) return canvas;
|
||||
|
||||
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const { data } = image;
|
||||
const contrast = 1 + amount * 1.45;
|
||||
const grayMix = amount * 0.9;
|
||||
const whitePush = amount * 0.42;
|
||||
const inkMix = Math.max(0, (amount - 0.55) / 0.45);
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
let r = data[i]!;
|
||||
let g = data[i + 1]!;
|
||||
let b = data[i + 2]!;
|
||||
const y = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||
r = r * (1 - grayMix) + y * grayMix;
|
||||
g = g * (1 - grayMix) + y * grayMix;
|
||||
b = b * (1 - grayMix) + y * grayMix;
|
||||
|
||||
r = (r - 128) * contrast + 128;
|
||||
g = (g - 128) * contrast + 128;
|
||||
b = (b - 128) * contrast + 128;
|
||||
|
||||
if (whitePush > 0) {
|
||||
const lift = (v: number) =>
|
||||
v > 155 ? v + (255 - v) * whitePush * ((v - 155) / 100) : v;
|
||||
r = lift(r);
|
||||
g = lift(g);
|
||||
b = lift(b);
|
||||
}
|
||||
|
||||
if (inkMix > 0) {
|
||||
const y2 = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||
const ink = Math.max(0, Math.min(1, (175 - y2) / 85));
|
||||
const doc = 255 * (1 - ink) + 18 * ink;
|
||||
r = r * (1 - inkMix) + doc * inkMix;
|
||||
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);
|
||||
type V3 = [number, number, number];
|
||||
const u0 = imageWidth / 2;
|
||||
const v0 = imageHeight / 2;
|
||||
const p = (pt: { x: number; y: number }): V3 => [pt.x - u0, pt.y - v0, 1];
|
||||
const cross = (a: V3, b: V3): V3 => [
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
];
|
||||
const dot = (a: V3, b: V3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
const m1 = p(c.topLeftCorner);
|
||||
const m2 = p(c.topRightCorner);
|
||||
const m3 = p(c.bottomLeftCorner);
|
||||
const m4 = p(c.bottomRightCorner);
|
||||
const c14 = cross(m1, m4);
|
||||
const k2 = dot(c14, m3) / dot(cross(m2, m4), m3);
|
||||
const k3 = dot(c14, m2) / dot(cross(m3, m4), m2);
|
||||
const n2 = m2.map((v, i) => k2 * v - m1[i]!) as V3;
|
||||
const n3 = m3.map((v, i) => k3 * v - m1[i]!) as V3;
|
||||
const denom = n2[2] * n3[2];
|
||||
if (!Number.isFinite(k2) || !Number.isFinite(k3) || Math.abs(denom) < 1e-9) {
|
||||
return edgeAspect(c);
|
||||
}
|
||||
|
||||
ctx.putImageData(image, 0, 0);
|
||||
return canvas;
|
||||
const f2 = -(n2[0] * n3[0] + n2[1] * n3[1]) / denom;
|
||||
const maxDim = Math.max(imageWidth, imageHeight);
|
||||
if (!(f2 > 0) || Math.sqrt(f2) < maxDim * 0.3 || Math.sqrt(f2) > maxDim * 6) {
|
||||
return edgeAspect(c);
|
||||
}
|
||||
const ratio = Math.sqrt(
|
||||
(n2[0] ** 2 + n2[1] ** 2 + n2[2] ** 2 * f2) /
|
||||
(n3[0] ** 2 + n3[1] ** 2 + n3[2] ** 2 * f2),
|
||||
);
|
||||
return Number.isFinite(ratio) && ratio > 0.15 && ratio < 7
|
||||
? ratio
|
||||
: edgeAspect(c);
|
||||
}
|
||||
|
||||
/** 흔한 용지 비율(A 계열, Letter)과 4% 안쪽이면 그 비율로 맞춘다 */
|
||||
function snapAspect(ratio: number) {
|
||||
for (const target of [A_RATIO, LETTER_RATIO]) {
|
||||
for (const t of [target, 1 / target]) {
|
||||
if (Math.abs(ratio / t - 1) < 0.04) return t;
|
||||
}
|
||||
}
|
||||
return ratio;
|
||||
}
|
||||
|
||||
/** 결과 크기: 비율은 용지 설정(자동이면 원근 추정), 긴 변은 원본에서 문서가 차지한 픽셀 수 기준
|
||||
* (1754–3508px = A4 150–300dpi, 원본보다 크게 늘리지 않음) */
|
||||
export function outputSizeForAspect(
|
||||
aspect: PaperAspect,
|
||||
corners: CornerPoints,
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
) {
|
||||
if (aspect === 'a4-portrait') return { width: 1240, height: 1754 };
|
||||
if (aspect === 'a4-landscape') return { width: 1754, height: 1240 };
|
||||
if (aspect === 'square') return { width: 1400, height: 1400 };
|
||||
let ratio: number;
|
||||
if (aspect === 'a4-portrait') ratio = 1 / A_RATIO;
|
||||
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(
|
||||
corners.topRightCorner.x - corners.topLeftCorner.x,
|
||||
corners.topRightCorner.y - corners.topLeftCorner.y,
|
||||
/** 원근 보정 (RGB → RGB). 가장자리는 늘려 채워 검은 테두리가 생기지 않게 한다 */
|
||||
function warpMat(
|
||||
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(
|
||||
corners.bottomRightCorner.x - corners.bottomLeftCorner.x,
|
||||
corners.bottomRightCorner.y - corners.bottomLeftCorner.y,
|
||||
from.delete();
|
||||
to.delete();
|
||||
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(
|
||||
corners.bottomLeftCorner.x - corners.topLeftCorner.x,
|
||||
corners.bottomLeftCorner.y - corners.topLeftCorner.y,
|
||||
);
|
||||
const heightRight = Math.hypot(
|
||||
corners.bottomRightCorner.x - corners.topRightCorner.x,
|
||||
corners.bottomRightCorner.y - corners.topRightCorner.y,
|
||||
);
|
||||
const width = Math.max(widthTop, widthBottom, 1);
|
||||
const height = Math.max(heightLeft, heightRight, 1);
|
||||
const scale = Math.min(1800 / width, 1800 / height, 1);
|
||||
const kernel = cv.getStructuringElement(cv.MORPH_ELLIPSE, new cv.Size(9, 9));
|
||||
cv.dilate(small, small, kernel);
|
||||
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,
|
||||
);
|
||||
flat.delete();
|
||||
return out;
|
||||
}
|
||||
levels(flat, 0.002 + 0.02 * a, 1 + 0.6 * a);
|
||||
sharpen(cv, flat, 0.3 + 0.5 * a);
|
||||
return flat;
|
||||
}
|
||||
|
||||
/** 원본을 캔버스에 그린다. 너무 크면 줄이고 그 배율을 돌려준다 */
|
||||
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 {
|
||||
width: Math.max(1, Math.round(width * scale)),
|
||||
height: Math.max(1, Math.round(height * scale)),
|
||||
topLeftCorner: f(c.topLeftCorner),
|
||||
topRightCorner: f(c.topRightCorner),
|
||||
bottomRightCorner: f(c.bottomRightCorner),
|
||||
bottomLeftCorner: f(c.bottomLeftCorner),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,28 +254,46 @@ export async function warpImage(
|
||||
corners: CornerPoints,
|
||||
aspect: PaperAspect,
|
||||
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 size = outputSizeForAspect(aspect, corners);
|
||||
const scanner = createScanner();
|
||||
const canvas = scanner.extractPaper(
|
||||
image,
|
||||
size.width,
|
||||
size.height,
|
||||
corners,
|
||||
);
|
||||
if (!canvas) {
|
||||
throw new Error('문서 영역을 추출하지 못했습니다.');
|
||||
const { canvas: source, scale } = sourceCanvas(image);
|
||||
const c = scaleCorners(corners, scale);
|
||||
const size = outputSizeForAspect(aspect, c, source.width, source.height);
|
||||
|
||||
const rgba = cv.imread(source);
|
||||
const rgb = new cv.Mat();
|
||||
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)}`,
|
||||
);
|
||||
} finally {
|
||||
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) => {
|
||||
canvas.toBlob(
|
||||
(value) =>
|
||||
value
|
||||
? resolve(value)
|
||||
: reject(new Error('보정 이미지를 만들지 못했습니다.')),
|
||||
'image/png',
|
||||
type,
|
||||
0.92,
|
||||
);
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -29,9 +29,14 @@ import {
|
||||
import type {
|
||||
CornerPoints,
|
||||
PaperAspect,
|
||||
ScanMode,
|
||||
ScannerPage,
|
||||
} 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';
|
||||
|
||||
defineOptions({ name: 'ToolsScannerView' });
|
||||
@@ -125,6 +130,7 @@ async function pushPageFromSource(
|
||||
sourceHeight: h,
|
||||
corners,
|
||||
aspect: 'auto',
|
||||
scanMode: DEFAULT_SCAN_MODE,
|
||||
scanIntensity: DEFAULT_SCAN_INTENSITY,
|
||||
warpedUrl: null,
|
||||
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() {
|
||||
if (!activePage.value || busy.value) return;
|
||||
await warpActive();
|
||||
@@ -482,6 +501,7 @@ async function warpActive() {
|
||||
activePage.value.corners,
|
||||
activePage.value.aspect,
|
||||
activePage.value.scanIntensity,
|
||||
activePage.value.scanMode,
|
||||
);
|
||||
revokeUrl(activePage.value.warpedUrl);
|
||||
activePage.value.warpedUrl = result.url;
|
||||
@@ -511,6 +531,7 @@ async function warpAll() {
|
||||
page.corners,
|
||||
page.aspect,
|
||||
page.scanIntensity,
|
||||
page.scanMode,
|
||||
);
|
||||
revokeUrl(page.warpedUrl);
|
||||
page.warpedUrl = result.url;
|
||||
@@ -536,6 +557,7 @@ async function runOcrOnPage(page: ScannerPage) {
|
||||
page.corners,
|
||||
page.aspect,
|
||||
page.scanIntensity,
|
||||
page.scanMode,
|
||||
);
|
||||
revokeUrl(page.warpedUrl);
|
||||
page.warpedUrl = result.url;
|
||||
@@ -605,6 +627,7 @@ async function exportPdf() {
|
||||
page.corners,
|
||||
page.aspect,
|
||||
page.scanIntensity,
|
||||
page.scanMode,
|
||||
);
|
||||
revokeUrl(page.warpedUrl);
|
||||
page.warpedUrl = warped.url;
|
||||
@@ -645,7 +668,9 @@ async function exportActivePng() {
|
||||
if (!activePage.value.warpedUrl) await warpActive();
|
||||
if (!activePage.value?.warpedUrl) return;
|
||||
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) {
|
||||
@@ -954,7 +979,7 @@ const progressPercent = computed(() =>
|
||||
:disabled="busy"
|
||||
@click="void exportActivePng()"
|
||||
>
|
||||
PNG로 저장
|
||||
이미지로 저장
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -984,6 +1009,22 @@ const progressPercent = computed(() =>
|
||||
<option value="auto">자동 비율</option>
|
||||
</select>
|
||||
</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">
|
||||
<span class="settings-field__row">
|
||||
스캔 강도
|
||||
@@ -1052,6 +1093,22 @@ const progressPercent = computed(() =>
|
||||
<option value="auto">자동 비율</option>
|
||||
</select>
|
||||
</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">
|
||||
<span class="settings-field__row">
|
||||
스캔 강도
|
||||
@@ -1073,7 +1130,7 @@ const progressPercent = computed(() =>
|
||||
@change="void commitScanIntensity()"
|
||||
/>
|
||||
<span class="settings-field__hint">
|
||||
0 원본 · 중간 대비 · 100 문서형
|
||||
0 은은하게 · 100 글자 진하고 선명하게
|
||||
</span>
|
||||
</label>
|
||||
<label class="settings-check">
|
||||
@@ -1105,7 +1162,7 @@ const progressPercent = computed(() =>
|
||||
void exportActivePng();
|
||||
"
|
||||
>
|
||||
PNG로 저장
|
||||
이미지로 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user