diff --git a/app/globals.css b/app/globals.css index 826a3e9..75ecda1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -52,7 +52,10 @@ button { color: inherit; } .upload-overlay { position: absolute; z-index: 3; right: 10px; bottom: 10px; padding: 7px 10px; border: 0; color: white; background: rgba(22, 75, 57, .9); cursor: pointer; font-size: 12px; } .file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin: 9px 0 24px; color: var(--muted); font-size: 11px; } .crop-select-button { width: 100%; margin-top: 0; padding: 10px; border: 1px solid var(--ink); background: var(--lime); cursor: pointer; font-size: 12px; font-weight: 700; } -.reset-crop { width: 100%; margin: 7px 0 20px; padding: 7px; border: 1px solid var(--line); background: white; cursor: pointer; color: var(--muted); font-size: 11px; } +.reset-crop { width: 100%; margin: 7px 0 0; padding: 7px; border: 1px solid var(--line); background: white; cursor: pointer; color: var(--muted); font-size: 11px; } +.pattern-import-button { width: 100%; margin: 7px 0 20px; padding: 9px; border: 1px solid #b6532d; background: #fff5ec; color: #8f3f20; cursor: pointer; font-size: 12px; font-weight: 700; } +.pattern-import-button:hover { background: #fee5d1; } +.pattern-import-file { display: none; } .field-row { display: grid; grid-template-columns: 1fr auto 1fr; align-items: end; gap: 8px; } .field-row label, .field { display: flex; flex-direction: column; gap: 8px; } .field-row label span, .field > span { color: var(--muted); font-size: 12px; } @@ -150,6 +153,12 @@ input[type="range"] { accent-color: var(--green); } .crop-mode-switch button { min-height: 34px; padding: 0 15px; border: 0; border-right: 1px solid var(--ink); background: transparent; cursor: pointer; font-size: 12px; } .crop-mode-switch button:last-child { border-right: 0; } .crop-mode-switch button.active { background: var(--green); color: white; font-weight: 700; } +.pattern-import-dimensions { display: flex; align-items: flex-end; gap: 8px; } +.pattern-import-dimensions label { display: grid; gap: 3px; color: var(--muted); font-size: 10px; } +.pattern-import-dimensions input { width: 82px; height: 34px; padding: 0 8px; border: 1px solid var(--line); background: white; color: var(--ink); font-size: 12px; } +.pattern-import-dimensions b { padding-bottom: 9px; color: var(--muted); font-weight: 400; } +.pattern-import-empty-option { display: flex; align-items: center; gap: 6px; color: var(--ink); cursor: pointer; } +.pattern-import-empty-option input { accent-color: var(--green); } .crop-size-readout { margin-left: auto; font-variant-numeric: tabular-nums; } .crop-dialog-scroll { min-height: 0; flex: 1 1 auto; overflow: auto; padding: 18px; display: grid; place-items: center; background-color: #e9e5db; background-image: radial-gradient(#c8c3b8 .7px, transparent .7px); background-size: 12px 12px; } .crop-dialog-stage { position: relative; width: max-content; max-width: 100%; line-height: 0; cursor: crosshair; touch-action: none; user-select: none; box-shadow: 0 0 0 1px var(--ink); } @@ -165,6 +174,7 @@ input[type="range"] { accent-color: var(--green); } .crop-selection-box::before, .crop-selection-box::after { content: ""; position: absolute; background: rgba(255,255,255,.62); } .crop-selection-box::before { left: 33.333%; top: 0; bottom: 0; width: 1px; box-shadow: calc(33.333% * 1) 0 0 rgba(255,255,255,.62); } .crop-selection-box::after { left: 0; right: 0; top: 33.333%; height: 1px; box-shadow: 0 calc(33.333% * 1) 0 rgba(255,255,255,.62); } +.pattern-import-grid-overlay { position: absolute; z-index: 3; inset: 0; display: block; pointer-events: none; background-image: linear-gradient(to right, rgba(255,102,0,.66) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,102,0,.66) 1px, transparent 1px); box-shadow: inset -1px -1px 0 rgba(255,102,0,.66); } .crop-resize-handle { position: absolute; z-index: 4; width: 15px; height: 15px; display: block; border: 2px solid white; border-radius: 50%; background: var(--green); box-shadow: 0 1px 4px rgba(0,0,0,.48); pointer-events: auto; } .handle-nw { left: 0; top: 0; transform: translate(-50%, -50%); cursor: nwse-resize; } .handle-ne { right: 0; top: 0; transform: translate(50%, -50%); cursor: nesw-resize; } @@ -222,10 +232,16 @@ input[type="range"] { accent-color: var(--green); } .crop-mode-bar { padding: 8px 12px; flex-wrap: wrap; gap: 7px 10px; } .crop-mode-switch { flex: 1; } .crop-mode-switch button { flex: 1; padding: 0 8px; } + .pattern-import-dimensions { width: 100%; } + .pattern-import-dimensions label { flex: 1; } + .pattern-import-dimensions input { width: 100%; min-height: 42px; } + .pattern-import-empty-option { width: 100%; min-height: 34px; } .crop-size-readout { width: 100%; margin-left: 0; } .crop-resize-handle { width: 28px; height: 28px; border-width: 3px; } .crop-edge-handle { display: none; } .crop-dialog-scroll { padding: 10px; } + .crop-dialog-stage { width: 100%; max-width: 100%; } + .crop-dialog-stage canvas { width: 100%; max-width: 100%; height: auto; } .crop-dialog-actions { padding: 10px 12px; flex-wrap: wrap; } .crop-dialog-actions span { width: 100%; order: -1; } .crop-dialog-actions button { flex: 1; padding: 0 10px; } diff --git a/app/page.tsx b/app/page.tsx index 1d23de5..8fc4c93 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -36,6 +36,7 @@ type CropRect = { }; type CropSelectionMode = "free" | "square"; +type CropDialogPurpose = "source" | "pattern-import"; type CropResizeHandle = "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se"; @@ -228,6 +229,231 @@ function nearestColor(lab: Oklab, palette: BeadColor[]) { return closest; } +type NormalizedGlyph = { + pixels: Uint8Array; + count: number; +}; + +let mardCodeTemplates: Array<{ code: string; glyph: NormalizedGlyph }> | null = null; + +function normalizeGlyph(mask: Uint8Array, width: number, height: number, targetWidth = 32, targetHeight = 16): NormalizedGlyph | null { + let left = width; + let right = -1; + let top = height; + let bottom = -1; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (!mask[y * width + x]) continue; + left = Math.min(left, x); + right = Math.max(right, x); + top = Math.min(top, y); + bottom = Math.max(bottom, y); + } + } + if (right < left || bottom < top) return null; + const sourceWidth = right - left + 1; + const sourceHeight = bottom - top + 1; + const scale = Math.min((targetWidth - 2) / sourceWidth, (targetHeight - 2) / sourceHeight); + const drawWidth = Math.max(1, Math.round(sourceWidth * scale)); + const drawHeight = Math.max(1, Math.round(sourceHeight * scale)); + const offsetX = Math.floor((targetWidth - drawWidth) / 2); + const offsetY = Math.floor((targetHeight - drawHeight) / 2); + const pixels = new Uint8Array(targetWidth * targetHeight); + let count = 0; + for (let y = 0; y < drawHeight; y++) { + for (let x = 0; x < drawWidth; x++) { + const sourceX = left + Math.min(sourceWidth - 1, Math.floor(x / scale)); + const sourceY = top + Math.min(sourceHeight - 1, Math.floor(y / scale)); + if (!mask[sourceY * width + sourceX]) continue; + pixels[(offsetY + y) * targetWidth + offsetX + x] = 1; + count += 1; + } + } + return count >= 3 ? { pixels, count } : null; +} + +function buildMardCodeTemplates() { + if (mardCodeTemplates) return mardCodeTemplates; + const canvas = document.createElement("canvas"); + canvas.width = 96; + canvas.height = 48; + const ctx = canvas.getContext("2d", { willReadFrequently: true })!; + mardCodeTemplates = PALETTE.map((color) => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = "#000000"; + ctx.font = "700 28px Arial"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(color.code, canvas.width / 2, canvas.height / 2); + const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data; + const mask = new Uint8Array(canvas.width * canvas.height); + for (let index = 0; index < mask.length; index++) { + const offset = index * 4; + const luminance = data[offset] * 0.299 + data[offset + 1] * 0.587 + data[offset + 2] * 0.114; + if (luminance < 150) mask[index] = 1; + } + return { code: color.code, glyph: normalizeGlyph(mask, canvas.width, canvas.height)! }; + }); + return mardCodeTemplates; +} + +function glyphSimilarity(first: NormalizedGlyph, second: NormalizedGlyph) { + let intersection = 0; + for (let index = 0; index < first.pixels.length; index++) { + if (first.pixels[index] && second.pixels[index]) intersection += 1; + } + return (2 * intersection) / Math.max(1, first.count + second.count); +} + +function dominantCellBackground(data: Uint8ClampedArray, canvasWidth: number, startX: number, startY: number, cellSize: number) { + const bins = new Map(); + const inset = Math.max(2, Math.floor(cellSize * 0.12)); + for (let y = inset; y < cellSize - inset; y++) { + for (let x = inset; x < cellSize - inset; x++) { + const nx = x / cellSize; + const ny = y / cellSize; + if (nx > 0.34 && nx < 0.66 && ny > 0.27 && ny < 0.73) continue; + const index = ((startY + y) * canvasWidth + startX + x) * 4; + const alpha = data[index + 3]; + const key = alpha < TRANSPARENT_ALPHA_THRESHOLD + ? -1 + : ((data[index] >> 4) << 8) | ((data[index + 1] >> 4) << 4) | (data[index + 2] >> 4); + const bin = bins.get(key) ?? { count: 0, red: 0, green: 0, blue: 0, alpha: 0 }; + bin.count += 1; + bin.red += data[index]; + bin.green += data[index + 1]; + bin.blue += data[index + 2]; + bin.alpha += alpha; + bins.set(key, bin); + } + } + const dominant = [...bins.values()].sort((a, b) => b.count - a.count)[0]; + if (!dominant) return { rgb: [255, 255, 255] as [number, number, number], alpha: 0 }; + return { + rgb: [ + Math.round(dominant.red / dominant.count), + Math.round(dominant.green / dominant.count), + Math.round(dominant.blue / dominant.count), + ] as [number, number, number], + alpha: Math.round(dominant.alpha / dominant.count), + }; +} + +function extractCellGlyph( + data: Uint8ClampedArray, + canvasWidth: number, + startX: number, + startY: number, + cellSize: number, + background: [number, number, number], +) { + const mask = new Uint8Array(cellSize * cellSize); + const marginX = Math.max(2, Math.floor(cellSize * 0.12)); + const marginY = Math.max(2, Math.floor(cellSize * 0.16)); + for (let y = marginY; y < cellSize - marginY; y++) { + for (let x = marginX; x < cellSize - marginX; x++) { + const index = ((startY + y) * canvasWidth + startX + x) * 4; + if (data[index + 3] < TRANSPARENT_ALPHA_THRESHOLD) continue; + const difference = Math.hypot( + data[index] - background[0], + data[index + 1] - background[1], + data[index + 2] - background[2], + ); + if (difference >= 72) mask[y * cellSize + x] = 1; + } + } + return normalizeGlyph(mask, cellSize, cellSize); +} + +function recognizeMardCode(glyph: NormalizedGlyph, backgroundLab: Oklab) { + let bestCode = ""; + let bestScore = 0; + const candidates = buildMardCodeTemplates() + .map((template) => ({ template, distance: oklabDistance(backgroundLab, PALETTE_BY_CODE.get(template.code)!.lab) })) + .sort((first, second) => first.distance - second.distance) + .slice(0, 24); + for (const { template, distance } of candidates) { + const rawScore = glyphSimilarity(glyph, template.glyph); + const colorPenalty = Math.min(0.16, distance * 0.35); + const score = rawScore - colorPenalty; + if (score > bestScore) { + bestScore = score; + bestCode = template.code; + } + } + return { code: bestCode, score: bestScore }; +} + +function importMardPatternImage( + image: HTMLImageElement, + crop: CropRect, + width: number, + height: number, + missingCodeAsEmpty: boolean, +) { + const maximumSide = 4096; + const cellSize = Math.max(10, Math.min(36, Math.floor(maximumSide / Math.max(width, height)))); + const canvas = document.createElement("canvas"); + canvas.width = width * cellSize; + canvas.height = height * cellSize; + const ctx = canvas.getContext("2d", { willReadFrequently: true })!; + ctx.imageSmoothingEnabled = true; + ctx.drawImage( + image, + crop.x * image.naturalWidth, + crop.y * image.naturalHeight, + crop.width * image.naturalWidth, + crop.height * image.naturalHeight, + 0, + 0, + canvas.width, + canvas.height, + ); + const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data; + const pixels: Pixel[] = []; + let recognizedCodes = 0; + let colorMatches = 0; + let emptyCells = 0; + const recognitionCache = new Map(); + for (let row = 0; row < height; row++) { + for (let column = 0; column < width; column++) { + const startX = column * cellSize; + const startY = row * cellSize; + const background = dominantCellBackground(data, canvas.width, startX, startY, cellSize); + const backgroundLab = rgbToOklab(background.rgb); + const glyph = extractCellGlyph(data, canvas.width, startX, startY, cellSize, background.rgb); + let recognized: { code: string; score: number } | null = null; + if (glyph) { + let key = ""; + for (let index = 0; index < glyph.pixels.length; index++) key += glyph.pixels[index] ? "1" : "0"; + recognized = recognitionCache.get(key) ?? recognizeMardCode(glyph, backgroundLab); + recognitionCache.set(key, recognized); + } + if (recognized && recognized.score >= 0.58 && PALETTE_BY_CODE.has(recognized.code)) { + pixels.push({ color: PALETTE_BY_CODE.get(recognized.code)! }); + recognizedCodes += 1; + continue; + } + if (missingCodeAsEmpty) { + pixels.push({ color: null }); + emptyCells += 1; + continue; + } + const chroma = Math.hypot(backgroundLab[1], backgroundLab[2]); + if (background.alpha < TRANSPARENT_ALPHA_THRESHOLD || (!glyph && backgroundLab[0] > 0.93 && chroma < 0.025)) { + pixels.push({ color: null }); + emptyCells += 1; + continue; + } + pixels.push({ color: nearestColor(backgroundLab, PALETTE) }); + colorMatches += 1; + } + } + return { pixels, recognizedCodes, colorMatches, emptyCells }; +} + function mergePerceptualColors(data: Uint8ClampedArray) { const pixelKeys: Array = []; const histogram = new Map(); @@ -755,6 +981,13 @@ export default function Home() { const [draftCropRect, setDraftCropRect] = useState(FULL_CROP); const [cropSelectionMode, setCropSelectionMode] = useState("free"); const [cropDialogOpen, setCropDialogOpen] = useState(false); + const [cropDialogPurpose, setCropDialogPurpose] = useState("source"); + const [patternImportWidth, setPatternImportWidth] = useState(100); + const [patternImportHeight, setPatternImportHeight] = useState(100); + const [missingCodeAsEmpty, setMissingCodeAsEmpty] = useState(true); + const [patternImportName, setPatternImportName] = useState(""); + const [patternImportUrl, setPatternImportUrl] = useState(""); + const [patternImportBusy, setPatternImportBusy] = useState(false); const [pixels, setPixels] = useState([]); const [selectedCodes, setSelectedCodes] = useState>(new Set()); const [onlySelected, setOnlySelected] = useState(false); @@ -768,6 +1001,8 @@ export default function Home() { const [status, setStatus] = useState("示例图已准备好,可以直接转换"); const sourceImageRef = useRef(null); const fileInputRef = useRef(null); + const patternImportInputRef = useRef(null); + const patternImportImageRef = useRef(null); const cropCanvasRef = useRef(null); const cropDialogCanvasRef = useRef(null); const canvasRef = useRef(null); @@ -791,6 +1026,9 @@ export default function Home() { moved: boolean; } | null>(null); const suppressCanvasClickRef = useRef(false); + const cropDialogImage = cropDialogPurpose === "pattern-import" + ? patternImportImageRef.current + : sourceImageRef.current; useEffect(() => { logUsage({ event: "page_view" }); @@ -914,7 +1152,7 @@ export default function Home() { useEffect(() => { if (!cropDialogOpen) return; const canvas = cropDialogCanvasRef.current; - const image = sourceImageRef.current; + const image = cropDialogImage; if (!canvas || !image) return; const maximumWidth = 1200; const maximumHeight = 760; @@ -924,7 +1162,7 @@ export default function Home() { const ctx = canvas.getContext("2d")!; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(image, 0, 0, canvas.width, canvas.height); - }, [cropDialogOpen, sourceUrl]); + }, [cropDialogOpen, cropDialogPurpose, sourceUrl, patternImportUrl, cropDialogImage]); useEffect(() => { if (!cropDialogOpen) return; @@ -1107,7 +1345,38 @@ export default function Home() { image.src = url; }; + const handlePatternImportUpload = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + if (!file.type.startsWith("image/")) { + setStatus("请选择 JPG、PNG 或 WebP 图纸图片"); + return; + } + const url = URL.createObjectURL(file); + const image = new Image(); + image.onload = () => { + if (patternImportUrl.startsWith("blob:") && patternImportUrl !== sourceUrl) URL.revokeObjectURL(patternImportUrl); + patternImportImageRef.current = image; + setPatternImportUrl(url); + setPatternImportName(file.name); + setPatternImportWidth(gridWidth); + setPatternImportHeight(gridHeight); + setCropDialogPurpose("pattern-import"); + setCropSelectionMode("free"); + setDraftCropRect(FULL_CROP); + setCropDialogOpen(true); + setStatus("图纸图片已载入,请框选实际网格并填写行列数"); + }; + image.onerror = () => { + URL.revokeObjectURL(url); + setStatus("图纸图片读取失败,请换一张图片重试"); + }; + image.src = url; + }; + const openCropDialog = () => { + setCropDialogPurpose("source"); setDraftCropRect(cropRect); setCropDialogOpen(true); }; @@ -1128,7 +1397,7 @@ export default function Home() { let width = Math.abs(endX - startX); let height = Math.abs(endY - startY); if (cropSelectionMode === "square") { - const image = sourceImageRef.current; + const image = cropDialogImage; if (!image) return; const normalizedSquareRatio = image.naturalHeight / image.naturalWidth; if (width / Math.max(height, 0.0001) > normalizedSquareRatio) width = height * normalizedSquareRatio; @@ -1161,7 +1430,7 @@ export default function Home() { const chooseCropSelectionMode = (mode: CropSelectionMode) => { setCropSelectionMode(mode); if (mode !== "square") return; - const image = sourceImageRef.current; + const image = cropDialogImage; if (!image) return; const currentCenterX = draftCropRect.x + draftCropRect.width / 2; const currentCenterY = draftCropRect.y + draftCropRect.height / 2; @@ -1267,11 +1536,65 @@ export default function Home() { if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }; + const confirmPatternImport = async () => { + const image = patternImportImageRef.current; + const width = Math.max(1, Math.min(256, Math.round(patternImportWidth))); + const height = Math.max(1, Math.min(256, Math.round(patternImportHeight))); + if (!image) { + setStatus("请先选择要导入的图纸图片"); + return; + } + if (draftCropRect.width < 0.03 || draftCropRect.height < 0.03) { + setStatus("网格区域太小,请重新框选"); + return; + } + setPatternImportBusy(true); + setStatus("正在读取 MARD 编码并匹配格子底色…"); + await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); + try { + const result = importMardPatternImage(image, draftCropRect, width, height, missingCodeAsEmpty); + const actualColors = new Set(result.pixels.flatMap(({ color }) => color ? [color.code] : [])).size; + const beadCount = result.pixels.reduce((total, { color }) => total + (color ? 1 : 0), 0); + setPixels(result.pixels); + setGridWidth(width); + setGridHeight(height); + setRequestedWidth(width); + setRequestedHeight(height); + setSelectedCodes(new Set()); + setCropRect(draftCropRect); + sourceImageRef.current = image; + setSourceUrl(patternImportUrl); + setSourceName(`导入图纸:${patternImportName}`); + setCropDialogOpen(false); + setStatus(`已导入 ${width} × ${height} · 读取编码 ${result.recognizedCodes} 格 · 底色匹配 ${result.colorMatches} 格 · 空白 ${result.emptyCells} 格`); + logUsage({ + event: "pattern_import", + width, + height, + recognized_codes: result.recognizedCodes, + color_matches: result.colorMatches, + transparent_cells: result.emptyCells, + actual_colors: actualColors, + bead_count: beadCount, + missing_code_as_empty: missingCodeAsEmpty, + }); + window.requestAnimationFrame(() => document.getElementById("pattern-preview")?.scrollIntoView({ behavior: "smooth", block: "start" })); + } catch { + setStatus("图纸识别失败,请确认选框与网格行列准确后重试"); + } finally { + setPatternImportBusy(false); + } + }; + const confirmCrop = () => { if (draftCropRect.width < 0.03 || draftCropRect.height < 0.03) { setStatus("框选区域太小,请在大图上拖出更大的选框"); return; } + if (cropDialogPurpose === "pattern-import") { + void confirmPatternImport(); + return; + } setCropRect(draftCropRect); setCropDialogOpen(false); convertImage(sourceImageRef.current, draftCropRect); @@ -1596,6 +1919,8 @@ export default function Home() { + +
@@ -1607,7 +1932,7 @@ export default function Home() { -

图片只在当前浏览器中处理,不会上传到服务器。

+

原图与现成图纸都只在当前浏览器中处理,不会上传到服务器。

@@ -1693,15 +2018,26 @@ export default function Home() { {cropDialogOpen &&
{ if (event.target === event.currentTarget) setCropDialogOpen(false); }}>
-

IMAGE CROP

框选需要转换的区域

框内拖动可移动,控制点可调整大小;生成格子时以所选区域正中心为采样基准。

+ {cropDialogPurpose === "pattern-import" + ?

MARD PATTERN IMPORT

导入现成 MARD 图纸

选框边缘请贴合实际格子区域,不要包含外侧行列标尺;再填写网格的列数和行数。

+ :

IMAGE CROP

框选需要转换的区域

框内拖动可移动,控制点可调整大小;生成格子时以所选区域正中心为采样基准。

}
- 选框形状 -
- - -
+ {cropDialogPurpose === "pattern-import" ? <> +
+ + × + +
+ + : <> + 选框形状 +
+ + +
+ } 当前选框:{Math.round(draftCropRect.width * 100)}% × {Math.round(draftCropRect.height * 100)}%
@@ -1720,6 +2056,11 @@ export default function Home() {
+ {cropDialogPurpose === "pattern-import" && patternImportWidth > 0 && patternImportHeight > 0 &&