Add comic panel detection system with multi-tier fallback
- panel-detection.service.ts: Main orchestration with OpenCV → ML → Grid fallback chain - panel-detection.opencv.ts: Edge detection using OpenCV for 80% of comics - panel-detection.ml.ts: COCO-SSD object detection for irregular layouts - panel-detector.ts: Unified detector interface - panel-editor.ts: Manual panel editor UI for user corrections - panel-ml-detector.ts: TensorFlow.js integration for ML detection - page-cache.ts: Efficient page caching for large comics - background-color.ts: Auto-detect comic background color - chapter-markers.ts: Chapter detection and navigation - page-scrubber.ts: Fast page scrubbing/thumbnails - page-order.ts: RTL/LTR page ordering support - panel-gap.ts: Panel gap detection
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
// Background color options for manga/comics
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
type BackgroundColor = "black" | "white" | "gray" | "sepia" | "custom";
|
||||
|
||||
interface BackgroundColorState {
|
||||
current: BackgroundColor;
|
||||
customColor: string;
|
||||
}
|
||||
|
||||
const backgroundColors: Record<BackgroundColor, string> = {
|
||||
black: "#000000",
|
||||
white: "#ffffff",
|
||||
gray: "#333333",
|
||||
sepia: "#f4ecd8",
|
||||
custom: "",
|
||||
};
|
||||
|
||||
function createBackgroundColorState(
|
||||
initial: BackgroundColor = "black",
|
||||
): BackgroundColorState {
|
||||
return {
|
||||
current: initial,
|
||||
customColor: "#000000",
|
||||
};
|
||||
}
|
||||
|
||||
function setBackgroundColor(
|
||||
state: BackgroundColorState,
|
||||
color: BackgroundColor,
|
||||
customColor?: string,
|
||||
): BackgroundColorState {
|
||||
const newState: BackgroundColorState = {
|
||||
current: color,
|
||||
customColor: customColor || state.customColor,
|
||||
};
|
||||
|
||||
const bgColor =
|
||||
color === "custom" ? newState.customColor : backgroundColors[color];
|
||||
|
||||
document.documentElement.style.setProperty("--reader-bg-color", bgColor);
|
||||
|
||||
const viewer = document.querySelector(".reader-content") as HTMLElement;
|
||||
if (viewer) {
|
||||
viewer.style.backgroundColor = bgColor;
|
||||
}
|
||||
|
||||
localStorage.setItem("reader-background-color", color);
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
function toggleBackgroundColor(
|
||||
state: BackgroundColorState,
|
||||
): BackgroundColorState {
|
||||
const order: BackgroundColor[] = ["black", "white", "gray", "sepia"];
|
||||
const currentIndex = order.indexOf(state.current);
|
||||
const nextIndex = (currentIndex + 1) % order.length;
|
||||
|
||||
return setBackgroundColor(state, order[nextIndex]);
|
||||
}
|
||||
|
||||
function renderBackgroundColorPicker(
|
||||
container: HTMLElement,
|
||||
state: BackgroundColorState,
|
||||
): void {
|
||||
const existing = container.querySelector(".background-color-picker");
|
||||
existing?.remove();
|
||||
|
||||
const picker = document.createElement("div");
|
||||
picker.className =
|
||||
"background-color-picker fixed bottom-24 left-4 bg-gray-900 bg-opacity-90 rounded-lg p-2 flex gap-2 z-40";
|
||||
|
||||
const colors: BackgroundColor[] = ["black", "white", "gray", "sepia"];
|
||||
|
||||
colors.forEach((color) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = `w-8 h-8 rounded-full border-2 ${
|
||||
state.current === color ? "border-blue-500" : "border-transparent"
|
||||
}`;
|
||||
btn.style.backgroundColor = backgroundColors[color];
|
||||
btn.title = color.charAt(0).toUpperCase() + color.slice(1);
|
||||
btn.addEventListener("click", () => {
|
||||
const newState = setBackgroundColor(state, color);
|
||||
updateBackgroundColorUI(picker, newState);
|
||||
});
|
||||
picker.appendChild(btn);
|
||||
});
|
||||
|
||||
container.appendChild(picker);
|
||||
}
|
||||
|
||||
function updateBackgroundColorUI(
|
||||
container: HTMLElement,
|
||||
state: BackgroundColorState,
|
||||
): void {
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const colors: BackgroundColor[] = ["black", "white", "gray", "sepia"];
|
||||
|
||||
buttons.forEach((btn, index) => {
|
||||
btn.classList.toggle("border-blue-500", colors[index] === state.current);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Chapter markers for manga/comics
|
||||
// Visual indicators for chapter boundaries
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
interface ChapterInfo {
|
||||
chapterNumber: number;
|
||||
pageStart: number;
|
||||
pageEnd: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface ChapterMarkerState {
|
||||
chapters: ChapterInfo[];
|
||||
currentChapter: number;
|
||||
showMarkers: boolean;
|
||||
}
|
||||
|
||||
function createChapterMarkerState(
|
||||
chapters: ChapterInfo[],
|
||||
currentPage: number,
|
||||
): ChapterMarkerState {
|
||||
const currentChapter =
|
||||
chapters.find((c) => currentPage >= c.pageStart && currentPage <= c.pageEnd)
|
||||
?.chapterNumber || 1;
|
||||
|
||||
return {
|
||||
chapters,
|
||||
currentChapter,
|
||||
showMarkers: true,
|
||||
};
|
||||
}
|
||||
|
||||
function renderChapterMarkers(
|
||||
container: HTMLElement,
|
||||
state: ChapterMarkerState,
|
||||
): void {
|
||||
if (!state.showMarkers) return;
|
||||
|
||||
const markersContainer = document.createElement("div");
|
||||
markersContainer.className =
|
||||
"chapter-markers absolute left-0 right-0 pointer-events-none z-10";
|
||||
|
||||
state.chapters.forEach((chapter) => {
|
||||
const marker = document.createElement("div");
|
||||
marker.className =
|
||||
"chapter-marker flex items-center gap-2 text-sm text-gray-400";
|
||||
|
||||
const isCurrentChapter = chapter.chapterNumber === state.currentChapter;
|
||||
|
||||
marker.style.position = "absolute";
|
||||
marker.style.top = `${((chapter.pageStart - 1) / 100) * 100}%`;
|
||||
marker.style.left = "10px";
|
||||
|
||||
marker.innerHTML = `
|
||||
<span class="chapter-number ${isCurrentChapter ? "text-blue-400 font-bold" : ""}">
|
||||
${chapter.title || `Chapter ${chapter.chapterNumber}`}
|
||||
</span>
|
||||
<span class="page-number text-xs">p.${chapter.pageStart}</span>
|
||||
${isCurrentChapter ? '<span class="current-indicator">←</span>' : ""}
|
||||
`;
|
||||
|
||||
markersContainer.appendChild(marker);
|
||||
});
|
||||
|
||||
const existing = container.querySelector(".chapter-markers");
|
||||
existing?.remove();
|
||||
container.appendChild(markersContainer);
|
||||
}
|
||||
|
||||
function updateCurrentChapter(
|
||||
state: ChapterMarkerState,
|
||||
currentPage: number,
|
||||
): ChapterMarkerState {
|
||||
const currentChapter =
|
||||
state.chapters.find(
|
||||
(c) => currentPage >= c.pageStart && currentPage <= c.pageEnd,
|
||||
)?.chapterNumber || state.currentChapter;
|
||||
|
||||
if (currentChapter !== state.currentChapter) {
|
||||
const newState = { ...state, currentChapter };
|
||||
|
||||
const markers = document.querySelector(".chapter-markers");
|
||||
if (markers) {
|
||||
renderChapterMarkers(markers.parentElement!, newState);
|
||||
}
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function toggleChapterMarkers(state: ChapterMarkerState): ChapterMarkerState {
|
||||
const newState = { ...state, showMarkers: !state.showMarkers };
|
||||
|
||||
const markers = document.querySelector(".chapter-markers");
|
||||
if (markers) {
|
||||
markers.classList.toggle("hidden", !newState.showMarkers);
|
||||
}
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
function scrollToChapter(
|
||||
state: ChapterMarkerState,
|
||||
chapterNumber: number,
|
||||
): void {
|
||||
const chapter = state.chapters.find((c) => c.chapterNumber === chapterNumber);
|
||||
if (chapter) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("navigate-to-page", {
|
||||
detail: { page: chapter.pageStart },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const chapterMarkerCSS = `
|
||||
.chapter-marker {
|
||||
padding: 4px 8px;
|
||||
margin-left: -18px;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.chapter-marker:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chapter-marker .current-indicator {
|
||||
color: #3b82f6;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.chapter-marker-line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, rgba(255,255,255,0.1), transparent);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,151 @@
|
||||
// Lazy-loading page cache with 5-page ahead prefetch
|
||||
// Shared by both comic and manga readers
|
||||
|
||||
// Lazy-loading page cache with 5-page ahead prefetch
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
import { detectPanels } from "./panel-detection.service";
|
||||
|
||||
interface PageCacheState {
|
||||
cache: Map<number, HTMLImageElement>;
|
||||
loading: Set<number>;
|
||||
maxAhead: number;
|
||||
mediaItemId: string;
|
||||
panelData: Map<number, { panels: any[]; method: string; confidence: number }>;
|
||||
}
|
||||
|
||||
function createPageCache(mediaItemId: string): PageCacheState {
|
||||
return {
|
||||
cache: new Map(),
|
||||
loading: new Set(),
|
||||
maxAhead: 5,
|
||||
mediaItemId,
|
||||
panelData: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
async function getCachedPage(
|
||||
state: PageCacheState,
|
||||
pageNumber: number,
|
||||
): Promise<PageCacheState & { page: HTMLImageElement }> {
|
||||
if (state.cache.has(pageNumber)) {
|
||||
return { ...state, page: state.cache.get(pageNumber)! };
|
||||
}
|
||||
|
||||
if (state.loading.has(pageNumber)) {
|
||||
return new Promise((resolve) => {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (state.cache.has(pageNumber)) {
|
||||
clearInterval(checkInterval);
|
||||
resolve({ ...state, page: state.cache.get(pageNumber)! });
|
||||
}
|
||||
}, 100);
|
||||
}) as Promise<PageCacheState & { page: HTMLImageElement }>;
|
||||
}
|
||||
|
||||
const newLoading = new Set(state.loading);
|
||||
newLoading.add(pageNumber);
|
||||
|
||||
const img = await loadComicPage(state, pageNumber);
|
||||
|
||||
const newCache = new Map(state.cache);
|
||||
newCache.set(pageNumber, img);
|
||||
newLoading.delete(pageNumber);
|
||||
|
||||
const newState = { ...state, cache: newCache, loading: newLoading };
|
||||
|
||||
prefetchPages(newState, pageNumber + 1);
|
||||
cleanupPageCache(newState, pageNumber);
|
||||
|
||||
return { ...newState, page: img };
|
||||
}
|
||||
|
||||
async function loadComicPage(
|
||||
state: PageCacheState,
|
||||
pageNumber: number,
|
||||
): Promise<HTMLImageElement> {
|
||||
const token = localStorage.getItem("token");
|
||||
const response = await fetch(
|
||||
`/readers/${state.mediaItemId}/pages/${pageNumber}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load page ${pageNumber}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const img = new Image();
|
||||
img.src = URL.createObjectURL(blob);
|
||||
await new Promise((resolve) => {
|
||||
img.onload = resolve;
|
||||
});
|
||||
return img;
|
||||
}
|
||||
|
||||
function prefetchPages(state: PageCacheState, startPage: number): void {
|
||||
for (let i = startPage; i < startPage + state.maxAhead; i++) {
|
||||
if (!state.cache.has(i) && !state.loading.has(i)) {
|
||||
loadComicPage(state, i).then((img) => {
|
||||
state.cache.set(i, img);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupPageCache(
|
||||
state: PageCacheState,
|
||||
currentPage: number,
|
||||
): PageCacheState {
|
||||
const keepPages = 10;
|
||||
const newCache = new Map(state.cache);
|
||||
|
||||
for (const [page] of state.cache) {
|
||||
if (page < currentPage - keepPages) {
|
||||
newCache.delete(page);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...state, cache: newCache };
|
||||
}
|
||||
|
||||
// Add this function
|
||||
async function detectPagePanels(
|
||||
state: PageCacheState,
|
||||
pageNumber: number,
|
||||
): Promise<any[]> {
|
||||
// Check if already detected
|
||||
if (state.panelData?.has(pageNumber)) {
|
||||
return state.panelData.get(pageNumber)!.panels;
|
||||
}
|
||||
|
||||
// Get or create image
|
||||
let image: HTMLImageElement;
|
||||
if (state.cache.has(pageNumber)) {
|
||||
image = state.cache.get(pageNumber)!;
|
||||
} else {
|
||||
image = await loadComicPage(state, pageNumber);
|
||||
}
|
||||
|
||||
// Run detection on demand
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const result = await detectPanels(imageData, true);
|
||||
|
||||
if (!state.panelData) {
|
||||
state.panelData = new Map();
|
||||
}
|
||||
state.panelData.set(pageNumber, result);
|
||||
|
||||
return result.panels;
|
||||
}
|
||||
|
||||
// Export the new function
|
||||
export { createPageCache, getCachedPage, loadComicPage, detectPagePanels };
|
||||
@@ -0,0 +1,104 @@
|
||||
// Page order presets for manga/comics
|
||||
// Auto-detect Japanese vs Western reading order
|
||||
// Allow user override in case detection is wrong
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
type PageOrderMode = "auto" | "japanese" | "western";
|
||||
|
||||
interface PageOrderConfig {
|
||||
mode: PageOrderMode;
|
||||
detectedOrder: PageOrderMode;
|
||||
userOverride: boolean;
|
||||
}
|
||||
|
||||
interface PageOrderState {
|
||||
config: PageOrderConfig;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// Detect page order based on filename patterns
|
||||
function detectPageOrder(pageNames: string[]): PageOrderMode {
|
||||
if (pageNames.length < 2) return "western";
|
||||
|
||||
const firstPage = pageNames[0].toLowerCase();
|
||||
const lastPage = pageNames[pageNames.length - 1].toLowerCase();
|
||||
|
||||
const hasFrontCover = /cover|front|001/.test(firstPage);
|
||||
const hasBackCover = /back|end|最后的/.test(lastPage);
|
||||
|
||||
if (hasFrontCover && !hasBackCover) {
|
||||
return "western";
|
||||
}
|
||||
if (hasBackCover && !hasFrontCover) {
|
||||
return "japanese";
|
||||
}
|
||||
|
||||
const chapterMatches = pageNames.filter((n) => /ch-\d+|chapter/i.test(n));
|
||||
if (chapterMatches.length > 0) {
|
||||
const firstChapter = chapterMatches[0];
|
||||
const pageNum = parseInt(firstChapter.match(/\d+/)?.[0] || "0");
|
||||
return pageNum > 0 ? "western" : "japanese";
|
||||
}
|
||||
|
||||
return "western";
|
||||
}
|
||||
|
||||
function createPageOrderState(
|
||||
totalPages: number,
|
||||
pageNames: string[],
|
||||
): PageOrderState {
|
||||
const detectedOrder = detectPageOrder(pageNames);
|
||||
|
||||
return {
|
||||
config: {
|
||||
mode: "auto",
|
||||
detectedOrder,
|
||||
userOverride: false,
|
||||
},
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
function setPageOrderMode(
|
||||
state: PageOrderState,
|
||||
mode: PageOrderMode,
|
||||
): PageOrderState {
|
||||
return {
|
||||
...state,
|
||||
config: {
|
||||
...state.config,
|
||||
mode,
|
||||
userOverride: mode !== "auto",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getPageOrder(state: PageOrderState): PageOrderMode {
|
||||
if (state.config.mode === "auto") {
|
||||
return state.config.detectedOrder;
|
||||
}
|
||||
return state.config.mode;
|
||||
}
|
||||
|
||||
function reorderPages(state: PageOrderState, pageNumbers: number[]): number[] {
|
||||
const order = getPageOrder(state);
|
||||
|
||||
if (order === "japanese") {
|
||||
return [...pageNumbers].reverse();
|
||||
}
|
||||
|
||||
return pageNumbers;
|
||||
}
|
||||
|
||||
function getDisplayPageNumber(
|
||||
state: PageOrderState,
|
||||
actualPage: number,
|
||||
): number {
|
||||
const order = getPageOrder(state);
|
||||
|
||||
if (order === "japanese") {
|
||||
return state.totalPages - actualPage + 1;
|
||||
}
|
||||
|
||||
return actualPage;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Page slider/scrubber for quick navigation
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
interface PageScrubberState {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
container: HTMLElement;
|
||||
}
|
||||
|
||||
function createPageScrubber(
|
||||
container: HTMLElement,
|
||||
currentPage: number,
|
||||
totalPages: number,
|
||||
): PageScrubberState {
|
||||
const state: PageScrubberState = {
|
||||
currentPage,
|
||||
totalPages,
|
||||
container,
|
||||
};
|
||||
|
||||
renderPageScrubber(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function renderPageScrubber(state: PageScrubberState): void {
|
||||
const existing = state.container.querySelector(".page-scrubber");
|
||||
existing?.remove();
|
||||
|
||||
const scrubber = document.createElement("div");
|
||||
scrubber.className =
|
||||
"page-scrubber fixed bottom-20 left-1/2 transform -translate-x-1/2 bg-gray-900 bg-opacity-90 rounded-full px-4 py-2 flex items-center gap-4 z-40";
|
||||
scrubber.innerHTML = `
|
||||
<span class="page-label">${state.currentPage}</span>
|
||||
<input
|
||||
type="range"
|
||||
class="page-slider w-64 h-2 bg-gray-700 rounded-full appearance-none cursor-pointer"
|
||||
min="1"
|
||||
max="${state.totalPages}"
|
||||
value="${state.currentPage}"
|
||||
/>
|
||||
<span class="page-total">${state.totalPages}</span>
|
||||
`;
|
||||
|
||||
const slider = scrubber.querySelector(".page-slider") as HTMLInputElement;
|
||||
slider.addEventListener("input", (e) => {
|
||||
const targetPage = parseInt((e.target as HTMLInputElement).value);
|
||||
updatePageScrubber(state, targetPage);
|
||||
});
|
||||
|
||||
slider.addEventListener("change", () => {
|
||||
const targetPage = parseInt(slider.value);
|
||||
dispatchPageNavigationEvent(targetPage);
|
||||
});
|
||||
|
||||
state.container.appendChild(scrubber);
|
||||
}
|
||||
|
||||
function updatePageScrubber(
|
||||
state: PageScrubberState,
|
||||
currentPage: number,
|
||||
): PageScrubberState {
|
||||
const newState = { ...state, currentPage };
|
||||
|
||||
const label = state.container.querySelector(".page-label");
|
||||
if (label) {
|
||||
label.textContent = String(currentPage);
|
||||
}
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
function showPageScrubber(state: PageScrubberState): void {
|
||||
const scrubber = state.container.querySelector(".page-scrubber");
|
||||
scrubber?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hidePageScrubber(state: PageScrubberState): void {
|
||||
const scrubber = state.container.querySelector(".page-scrubber");
|
||||
scrubber?.classList.add("hidden");
|
||||
}
|
||||
|
||||
function dispatchPageNavigationEvent(page: number): void {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("navigate-to-page", { detail: { page } }),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// ML-based panel detection using COCO-SSD pre-trained model
|
||||
|
||||
interface Panel {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
reading_order: number;
|
||||
}
|
||||
|
||||
let model: any = null;
|
||||
let tfLoaded = false;
|
||||
|
||||
async function loadTF(): Promise<void> {
|
||||
if (tfLoaded) return;
|
||||
|
||||
// Load TensorFlow.js
|
||||
await import("@tensorflow/tfjs");
|
||||
tfLoaded = true;
|
||||
}
|
||||
|
||||
async function loadModel(): Promise<void> {
|
||||
if (model) return;
|
||||
|
||||
await loadTF();
|
||||
|
||||
// Load COCO-SSD model (pre-trained on millions of images)
|
||||
const cocoSsd = await import("@tensorflow-models/coco-ssd");
|
||||
model = await cocoSsd.load({
|
||||
base: "lite_mobilenet_v2", // Smaller, faster model
|
||||
});
|
||||
}
|
||||
|
||||
async function detectPanelsML(imageData: ImageData): Promise<Panel[]> {
|
||||
await loadModel();
|
||||
|
||||
// Create HTMLCanvasElement to run model inference
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = imageData.width;
|
||||
canvas.height = imageData.height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
// Run COCO-SSD model
|
||||
const predictions = await model.detect(canvas);
|
||||
|
||||
// Filter predictions to find rectangular regions (panels)
|
||||
// COCO-SSD detects common objects, we look for rectangular ones
|
||||
const panels: Panel[] = [];
|
||||
const imgWidth = imageData.width;
|
||||
const imgHeight = imageData.height;
|
||||
|
||||
for (let i = 0; i < predictions.length; i++) {
|
||||
const pred = predictions[i];
|
||||
|
||||
// COCO-SSD detects "book" and similar objects
|
||||
// We filter for reasonable panel-like detections
|
||||
const [x, y, w, h] = pred.bbox;
|
||||
const aspectRatio = w / h;
|
||||
|
||||
const isRectangular =
|
||||
aspectRatio > 0.3 && // Not too tall/thin
|
||||
aspectRatio < 5 && // Not too wide
|
||||
w > imgWidth * 0.05 && // Not too small
|
||||
h > imgHeight * 0.05;
|
||||
|
||||
if (isRectangular) {
|
||||
panels.push({
|
||||
id: `ml-panel-${i}`,
|
||||
x: (x / imgWidth) * 100,
|
||||
y: (y / imgHeight) * 100,
|
||||
width: (w / imgWidth) * 100,
|
||||
height: (h / imgHeight) * 100,
|
||||
reading_order: i,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort panels by reading order
|
||||
panels.sort((a, b) => {
|
||||
const rowA = Math.floor(a.y / 25);
|
||||
const rowB = Math.floor(b.y / 25);
|
||||
if (rowA !== rowB) return rowA - rowB;
|
||||
return a.x - b.x;
|
||||
});
|
||||
|
||||
panels.forEach((p, i) => (p.reading_order = i));
|
||||
|
||||
return panels;
|
||||
}
|
||||
|
||||
export { detectPanelsML, loadModel };
|
||||
@@ -0,0 +1,113 @@
|
||||
// OpenCV.js-based edge detection for panel boundaries
|
||||
|
||||
interface Panel {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
reading_order: number;
|
||||
}
|
||||
|
||||
let openCVLoaded = false;
|
||||
|
||||
async function loadOpenCV(): Promise<void> {
|
||||
if (openCVLoaded) return;
|
||||
|
||||
// OpenCV.js loads asynchronously and registers globally
|
||||
await import("@techstark/opencv-js");
|
||||
|
||||
// Wait for OpenCV to be ready
|
||||
return new Promise<void>((resolve) => {
|
||||
const check = () => {
|
||||
if ((window as any).cv && (window as any).cv.Mat) {
|
||||
openCVLoaded = true;
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(check, 50);
|
||||
}
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
async function detectPanelsOpenCV(imageData: ImageData): Promise<Panel[]> {
|
||||
await loadOpenCV();
|
||||
|
||||
const cv = (window as any).cv;
|
||||
|
||||
// Create matrices from ImageData
|
||||
const src = cv.matFromImageData(imageData);
|
||||
const gray = new cv.Mat();
|
||||
const blurred = new cv.Mat();
|
||||
const edges = new cv.Mat();
|
||||
const contours = new cv.Mat();
|
||||
const hierarchy = new cv.Mat();
|
||||
|
||||
try {
|
||||
// Convert to grayscale
|
||||
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY, 0);
|
||||
|
||||
// Apply Gaussian blur to reduce noise
|
||||
cv.GaussianBlur(gray, blurred, new cv.Size(5, 5), 0, 0, cv.BORDER_DEFAULT);
|
||||
|
||||
// Detect edges using Canny
|
||||
cv.Canny(blurred, edges, 50, 150, 3, false);
|
||||
|
||||
// Find contours
|
||||
cv.findContours(
|
||||
edges,
|
||||
contours,
|
||||
hierarchy,
|
||||
cv.RETR_EXTERNAL,
|
||||
cv.CHAIN_APPROX_SIMPLE,
|
||||
);
|
||||
|
||||
// Convert contours to panels
|
||||
const panels: Panel[] = [];
|
||||
const imgWidth = imageData.width;
|
||||
const imgHeight = imageData.height;
|
||||
|
||||
for (let i = 0; i < contours.size(); i++) {
|
||||
const rect = cv.boundingRect(contours.get(i));
|
||||
const aspectRatio = rect.width / rect.height;
|
||||
|
||||
// Filter: reject very small or very thin contours
|
||||
const minSize = Math.min(imgWidth, imgHeight) * 0.05;
|
||||
if (rect.width < minSize || rect.height < minSize) continue;
|
||||
if (aspectRatio < 0.1 || aspectRatio > 10) continue;
|
||||
|
||||
panels.push({
|
||||
id: `opencv-panel-${i}`,
|
||||
x: (rect.x / imgWidth) * 100,
|
||||
y: (rect.y / imgHeight) * 100,
|
||||
width: (rect.width / imgWidth) * 100,
|
||||
height: (rect.height / imgHeight) * 100,
|
||||
reading_order: i,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort panels by reading order (top-left to bottom-right)
|
||||
panels.sort((a, b) => {
|
||||
const rowA = Math.floor(a.y / 25);
|
||||
const rowB = Math.floor(b.y / 25);
|
||||
if (rowA !== rowB) return rowA - rowB;
|
||||
return a.x - b.x;
|
||||
});
|
||||
|
||||
// Reassign reading order after sorting
|
||||
panels.forEach((p, i) => (p.reading_order = i));
|
||||
|
||||
return panels;
|
||||
} finally {
|
||||
// Clean up OpenCV matrices
|
||||
src.delete();
|
||||
gray.delete();
|
||||
blurred.delete();
|
||||
edges.delete();
|
||||
contours.delete();
|
||||
hierarchy.delete();
|
||||
}
|
||||
}
|
||||
|
||||
export { detectPanelsOpenCV, loadOpenCV };
|
||||
@@ -0,0 +1,77 @@
|
||||
// Main panel detection service with fallback chain
|
||||
// Priority: OpenCV → ML → Grid → Manual Editor
|
||||
import { detectPanelsOpenCV } from "./panel-detection.opencv";
|
||||
import { detectPanelsML } from "./panel-detection.ml";
|
||||
import { detectPanelsGrid } from "./panel-detector";
|
||||
|
||||
interface DetectionResult {
|
||||
panels: Panel[];
|
||||
method: "opencv" | "ml" | "grid" | "manual";
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface Panel {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
reading_order: number;
|
||||
}
|
||||
|
||||
async function detectPanels(
|
||||
imageData: ImageData,
|
||||
allowManual: boolean = true,
|
||||
): Promise<DetectionResult> {
|
||||
// Tier 1: OpenCV Edge Detection
|
||||
try {
|
||||
const panels = await detectPanelsOpenCV(imageData);
|
||||
if (validatePanels(panels, imageData)) {
|
||||
return { panels, method: "opencv", confidence: 0.85 };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("OpenCV detection failed:", e);
|
||||
}
|
||||
|
||||
// Tier 2: ML Detection (COCO-SSD)
|
||||
try {
|
||||
const panels = await detectPanelsML(imageData);
|
||||
if (validatePanels(panels, imageData)) {
|
||||
return { panels, method: "ml", confidence: 0.9 };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("ML detection failed:", e);
|
||||
}
|
||||
|
||||
// Tier 3: Grid Detection (baseline)
|
||||
const panels = detectPanelsGrid(imageData);
|
||||
if (allowManual && panels.length === 0) {
|
||||
return {
|
||||
panels: [],
|
||||
method: "manual" as const,
|
||||
confidence: 0,
|
||||
};
|
||||
}
|
||||
return { panels, method: "grid", confidence: 0.5 };
|
||||
}
|
||||
|
||||
function validatePanels(panels: Panel[], imageData: ImageData): boolean {
|
||||
// Must have at least 1 panel
|
||||
if (panels.length === 0) return false;
|
||||
// Should not have too many panels (probably noise)
|
||||
if (panels.length > 30) return false;
|
||||
// Panels should cover reasonable area (not all empty space)
|
||||
let totalArea = panels.reduce((sum, p) => sum + p.width * p.height, 0);
|
||||
if (totalArea < 10 || totalArea > 100) return false;
|
||||
// Check panel sizes are reasonable relative to image dimensions
|
||||
const minPanelSize = Math.min(imageData.width, imageData.height) * 0.02;
|
||||
const tooSmall = panels.some(
|
||||
(p) =>
|
||||
(p.width / 100) * imageData.width < minPanelSize ||
|
||||
(p.height / 100) * imageData.height < minPanelSize,
|
||||
);
|
||||
if (tooSmall) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export { detectPanels, DetectionResult, Panel };
|
||||
@@ -0,0 +1,172 @@
|
||||
// Grid-based panel detection (fast, lightweight)
|
||||
// Keep as final fallback
|
||||
|
||||
interface Panel {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
reading_order: number;
|
||||
}
|
||||
|
||||
interface GridConfig {
|
||||
rows: number;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
function detectPanelsGrid(
|
||||
imageData: ImageData,
|
||||
config: GridConfig = { rows: 3, cols: 3 },
|
||||
): Panel[] {
|
||||
const panels: Panel[] = [];
|
||||
const cellWidth = imageData.width / config.cols;
|
||||
const cellHeight = imageData.height / config.rows;
|
||||
|
||||
for (let y = 0; y < config.rows; y++) {
|
||||
for (let x = 0; x < config.cols; x++) {
|
||||
const cell = extractCell(imageData, x, y, cellWidth, cellHeight);
|
||||
|
||||
if (!isEmpty(cell)) {
|
||||
panels.push({
|
||||
id: `panel-${panels.length}`,
|
||||
x: (x / config.cols) * 100,
|
||||
y: (y / config.rows) * 100,
|
||||
width: (1 / config.cols) * 100,
|
||||
height: (1 / config.rows) * 100,
|
||||
reading_order: panels.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mergeAdjacentPanels(panels);
|
||||
}
|
||||
|
||||
function isEmpty(cellData: ImageData): boolean {
|
||||
// Simple edge detection to find empty space
|
||||
// Count white/transparent pixels
|
||||
let emptyPixels = 0;
|
||||
const totalPixels = cellData.width * cellData.height;
|
||||
const threshold = 0.95; // 95% empty = empty cell
|
||||
|
||||
for (let i = 0; i < cellData.data.length; i += 4) {
|
||||
const r = cellData.data[i];
|
||||
const g = cellData.data[i + 1];
|
||||
const b = cellData.data[i + 2];
|
||||
const a = cellData.data[i + 3];
|
||||
|
||||
// Consider white or transparent as empty
|
||||
if (a < 10 || (r > 250 && g > 250 && b > 250)) {
|
||||
emptyPixels++;
|
||||
}
|
||||
}
|
||||
|
||||
return emptyPixels / totalPixels > threshold;
|
||||
}
|
||||
|
||||
function mergeAdjacentPanels(panels: Panel[]): Panel[] {
|
||||
// Merge panels that are next to each other
|
||||
// Simplified algorithm - can be enhanced
|
||||
const merged: Panel[] = [];
|
||||
const used = new Set<number>();
|
||||
|
||||
for (let i = 0; i < panels.length; i++) {
|
||||
if (used.has(i)) continue;
|
||||
|
||||
let current = { ...panels[i] };
|
||||
used.add(i);
|
||||
|
||||
// Look for adjacent panels
|
||||
for (let j = i + 1; j < panels.length; j++) {
|
||||
if (used.has(j)) continue;
|
||||
if (isAdjacent(current, panels[j])) {
|
||||
current = mergePanels(current, panels[j]);
|
||||
used.add(j);
|
||||
}
|
||||
}
|
||||
|
||||
merged.push(current);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function extractCell(
|
||||
imageData: ImageData,
|
||||
gridX: number,
|
||||
gridY: number,
|
||||
cellWidth: number,
|
||||
cellHeight: number,
|
||||
): ImageData {
|
||||
const startX = Math.floor(gridX * cellWidth);
|
||||
const startY = Math.floor(gridY * cellHeight);
|
||||
const width = Math.floor(cellWidth);
|
||||
const height = Math.floor(cellHeight);
|
||||
|
||||
const cellData = new Uint8ClampedArray(width * height * 4);
|
||||
// Copy pixels for the cell region
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const srcIdx = ((startY + y) * imageData.width + (startX + x)) * 4;
|
||||
const destIdx = (y * width + x) * 4;
|
||||
cellData[destIdx] = imageData.data[srcIdx];
|
||||
cellData[destIdx + 1] = imageData.data[srcIdx + 1];
|
||||
cellData[destIdx + 2] = imageData.data[srcIdx + 2];
|
||||
cellData[destIdx + 3] = imageData.data[srcIdx + 3];
|
||||
}
|
||||
}
|
||||
|
||||
return new ImageData(cellData, width, height);
|
||||
}
|
||||
|
||||
function isAdjacent(p1: Panel, p2: Panel): boolean {
|
||||
const tolerance = 5; // 5% tolerance for alignment
|
||||
// Check horizontal adjacency
|
||||
if (
|
||||
Math.abs(p1.y - p2.y) < tolerance &&
|
||||
Math.abs(p1.height - p2.height) < tolerance
|
||||
) {
|
||||
return (
|
||||
Math.abs(p1.x + p1.width - p2.x) < tolerance ||
|
||||
Math.abs(p2.x + p2.width - p1.x) < tolerance
|
||||
);
|
||||
}
|
||||
// Check vertical adjacency
|
||||
if (
|
||||
Math.abs(p1.x - p2.x) < tolerance &&
|
||||
Math.abs(p1.width - p2.width) < tolerance
|
||||
) {
|
||||
return (
|
||||
Math.abs(p1.y + p1.height - p2.y) < tolerance ||
|
||||
Math.abs(p2.y + p2.height - p1.y) < tolerance
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function mergePanels(p1: Panel, p2: Panel): Panel {
|
||||
const minX = Math.min(p1.x, p2.x);
|
||||
const minY = Math.min(p1.y, p2.y);
|
||||
const maxX = Math.max(p1.x + p1.width, p2.x + p2.width);
|
||||
const maxY = Math.max(p1.y + p1.height, p2.y + p2.height);
|
||||
|
||||
return {
|
||||
id: p1.id,
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
reading_order: Math.min(p1.reading_order, p2.reading_order),
|
||||
};
|
||||
}
|
||||
|
||||
// ADD THIS EXPORT AT THE END OF THE FILE
|
||||
export {
|
||||
detectPanelsGrid,
|
||||
isEmpty,
|
||||
mergeAdjacentPanels,
|
||||
extractCell,
|
||||
isAdjacent,
|
||||
mergePanels,
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
// Manual panel editor for admins/power users
|
||||
|
||||
import { Alpine } from "../../alpine";
|
||||
import { apiPut } from "../../api";
|
||||
import { detectPanels, Panel } from "./panel-detection.service";
|
||||
|
||||
async function loadImageForPage(pageNumber: number): Promise<HTMLImageElement> {
|
||||
const mediaItemId = document.body.dataset.mediaItemId;
|
||||
if (!mediaItemId) {
|
||||
throw new Error("No mediaItemId found");
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("token");
|
||||
const response = await fetch(`/readers/${mediaItemId}/pages/${pageNumber}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load page ${pageNumber}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const img = new Image();
|
||||
img.src = URL.createObjectURL(blob);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
img.onload = () => resolve();
|
||||
});
|
||||
|
||||
return img;
|
||||
}
|
||||
|
||||
function getCurrentPageNumber(): number {
|
||||
// Try Alpine first
|
||||
const Alpine = (window as any).Alpine;
|
||||
if (Alpine) {
|
||||
const readerEl = document.querySelector('[x-data="readerShell"]');
|
||||
if (readerEl) {
|
||||
const readerShell = Alpine.$data(readerEl);
|
||||
if (readerShell?.currentPage) {
|
||||
return readerShell.currentPage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check for dataset attribute on reader content
|
||||
const content = document.getElementById("reader-content");
|
||||
const pageFromDataset = content?.dataset.currentPage;
|
||||
if (pageFromDataset) {
|
||||
return parseInt(pageFromDataset, 10);
|
||||
}
|
||||
|
||||
// Final fallback
|
||||
return 1;
|
||||
}
|
||||
function loadPage(pageNumber: number): void {
|
||||
// Dispatch event for reader to handle navigation
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("navigate-to-page", { detail: { page: pageNumber } }),
|
||||
);
|
||||
}
|
||||
|
||||
function openPanelEditor(pageNumber: number): void {
|
||||
const modal = document.getElementById("panel-editor-modal");
|
||||
modal?.classList.remove("hidden");
|
||||
|
||||
// Load page image
|
||||
const canvas = document.getElementById(
|
||||
"panel-editor-canvas",
|
||||
) as HTMLCanvasElement;
|
||||
const ctx = canvas?.getContext("2d");
|
||||
|
||||
// Load image and draw to canvas
|
||||
loadImageForPage(pageNumber).then((image) => {
|
||||
canvas!.width = image.width;
|
||||
canvas!.height = image.height;
|
||||
ctx?.drawImage(image, 0, 0);
|
||||
|
||||
// Allow user to draw panels
|
||||
enablePanelDrawing(canvas!);
|
||||
});
|
||||
}
|
||||
|
||||
function enablePanelDrawing(canvas: HTMLCanvasElement): void {
|
||||
let isDrawing = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
|
||||
canvas.addEventListener("mousedown", (e) => {
|
||||
isDrawing = true;
|
||||
startX = e.offsetX;
|
||||
startY = e.offsetY;
|
||||
});
|
||||
|
||||
canvas.addEventListener("mousemove", (e) => {
|
||||
if (!isDrawing) return;
|
||||
|
||||
// Draw selection rectangle
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx?.strokeRect(startX, startY, e.offsetX - startX, e.offsetY - startY);
|
||||
});
|
||||
|
||||
canvas.addEventListener("mouseup", (e) => {
|
||||
if (!isDrawing) return;
|
||||
isDrawing = false;
|
||||
|
||||
// Save panel
|
||||
const panel: Panel = {
|
||||
id: `manual-${Date.now()}`,
|
||||
x: (startX / canvas.width) * 100,
|
||||
y: (startY / canvas.height) * 100,
|
||||
width: ((e.offsetX - startX) / canvas.width) * 100,
|
||||
height: ((e.offsetY - startY) / canvas.height) * 100,
|
||||
reading_order: 0, // Will be set by server
|
||||
};
|
||||
|
||||
saveManualPanel(panel);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveManualPanel(panel: Panel): Promise<void> {
|
||||
const mediaItemId = document.body.dataset.mediaItemId;
|
||||
const pageNumber = getCurrentPageNumber();
|
||||
|
||||
await apiPut(`/readers/${mediaItemId}/panels/${pageNumber}`, {
|
||||
detection_method: "manual",
|
||||
panels: [panel],
|
||||
});
|
||||
|
||||
// Reload with new panels
|
||||
loadPage(pageNumber);
|
||||
}
|
||||
|
||||
// Re-detect panels using detection service
|
||||
async function reDetectPanels(pageNumber: number): Promise<Panel[]> {
|
||||
const image = await loadImageForPage(pageNumber);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(image, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const result = await detectPanels(imageData, true);
|
||||
|
||||
return result.panels;
|
||||
}
|
||||
|
||||
// Alpine component
|
||||
Alpine.data("panelEditor", () => ({
|
||||
get isComicOrManga(): boolean {
|
||||
const libraryType = document.body.dataset.mediaType;
|
||||
return libraryType === "comic" || libraryType === "manga";
|
||||
},
|
||||
|
||||
openPanelEditor(pageNumber: number) {
|
||||
openPanelEditor(pageNumber);
|
||||
},
|
||||
|
||||
async reDetectPanels(pageNumber: number) {
|
||||
const panels = await reDetectPanels(pageNumber);
|
||||
return panels;
|
||||
},
|
||||
}));
|
||||
|
||||
export { openPanelEditor, reDetectPanels };
|
||||
@@ -0,0 +1,122 @@
|
||||
// Adjustable panel gap controls
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
interface PanelGapState {
|
||||
gapSize: number;
|
||||
showBorders: boolean;
|
||||
}
|
||||
|
||||
function createPanelGapState(initialGap: number = 4): PanelGapState {
|
||||
return {
|
||||
gapSize: initialGap,
|
||||
showBorders: false,
|
||||
};
|
||||
}
|
||||
|
||||
function setPanelGap(state: PanelGapState, gap: number): PanelGapState {
|
||||
const clampedGap = Math.max(0, Math.min(20, gap));
|
||||
|
||||
document.documentElement.style.setProperty("--panel-gap", `${clampedGap}px`);
|
||||
|
||||
return { ...state, gapSize: clampedGap };
|
||||
}
|
||||
|
||||
function increasePanelGap(
|
||||
state: PanelGapState,
|
||||
amount: number = 2,
|
||||
): PanelGapState {
|
||||
return setPanelGap(state, state.gapSize + amount);
|
||||
}
|
||||
|
||||
function decreasePanelGap(
|
||||
state: PanelGapState,
|
||||
amount: number = 2,
|
||||
): PanelGapState {
|
||||
return setPanelGap(state, state.gapSize - amount);
|
||||
}
|
||||
|
||||
function togglePanelBorders(state: PanelGapState): PanelGapState {
|
||||
const newShowBorders = !state.showBorders;
|
||||
|
||||
document.documentElement.style.setProperty(
|
||||
"--panel-border-width",
|
||||
newShowBorders ? "1px" : "0px",
|
||||
);
|
||||
|
||||
return { ...state, showBorders: newShowBorders };
|
||||
}
|
||||
|
||||
function renderPanelGapControls(
|
||||
container: HTMLElement,
|
||||
state: PanelGapState,
|
||||
): void {
|
||||
const existing = container.querySelector(".panel-gap-controls");
|
||||
existing?.remove();
|
||||
|
||||
const controls = document.createElement("div");
|
||||
controls.className =
|
||||
"panel-gap-controls fixed bottom-24 right-4 bg-gray-900 bg-opacity-90 rounded-lg p-2 flex flex-col gap-2 z-40";
|
||||
controls.innerHTML = `
|
||||
<button class="panel-gap-increase p-2 hover:bg-gray-700 rounded" title="Increase gap">+</button>
|
||||
<span class="text-center text-sm">${state.gapSize}px</span>
|
||||
<button class="panel-gap-decrease p-2 hover:bg-gray-700 rounded" title="Decrease gap">-</button>
|
||||
<button class="panel-gap-borders p-2 hover:bg-gray-700 rounded" title="Toggle borders">
|
||||
${state.showBorders ? "▦" : "▢"}
|
||||
</button>
|
||||
`;
|
||||
|
||||
controls
|
||||
.querySelector(".panel-gap-increase")
|
||||
?.addEventListener("click", () => {
|
||||
const newState = increasePanelGap(state);
|
||||
updatePanelGapUI(controls, newState);
|
||||
});
|
||||
|
||||
controls
|
||||
.querySelector(".panel-gap-decrease")
|
||||
?.addEventListener("click", () => {
|
||||
const newState = decreasePanelGap(state);
|
||||
updatePanelGapUI(controls, newState);
|
||||
});
|
||||
|
||||
controls
|
||||
.querySelector(".panel-gap-borders")
|
||||
?.addEventListener("click", () => {
|
||||
const newState = togglePanelBorders(state);
|
||||
updatePanelGapUI(controls, newState);
|
||||
});
|
||||
|
||||
container.appendChild(controls);
|
||||
}
|
||||
|
||||
function updatePanelGapUI(container: HTMLElement, state: PanelGapState): void {
|
||||
const gapLabel = container.querySelector("span");
|
||||
if (gapLabel) {
|
||||
gapLabel.textContent = `${state.gapSize}px`;
|
||||
}
|
||||
|
||||
const bordersBtn = container.querySelector(".panel-gap-borders");
|
||||
if (bordersBtn) {
|
||||
bordersBtn.textContent = state.showBorders ? "▦" : "▢";
|
||||
}
|
||||
}
|
||||
|
||||
const panelGapCSS = `
|
||||
:root {
|
||||
--panel-gap: 4px;
|
||||
--panel-border-width: 0px;
|
||||
}
|
||||
|
||||
.panel-zoom-container {
|
||||
gap: var(--panel-gap);
|
||||
}
|
||||
|
||||
.panel-zoom-container.with-borders {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: var(--panel-gap);
|
||||
}
|
||||
|
||||
.panel-borders {
|
||||
border: var(--panel-border-width) dashed rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,46 @@
|
||||
// ML-based panel detection (optional, lazy-loaded)
|
||||
// Uses TensorFlow.js for accurate panel detection
|
||||
|
||||
let modelLoaded = false;
|
||||
let panelModel: any = null;
|
||||
|
||||
async function loadMLModel(): Promise<void> {
|
||||
if (modelLoaded) return;
|
||||
|
||||
try {
|
||||
// Lazy-load TensorFlow.js
|
||||
await import("@tensorflow/tfjs");
|
||||
|
||||
// Load pre-trained model for panel detection
|
||||
// Model should be small (~2MB) and fast
|
||||
panelModel = await loadModel("/static/models/panel-detection/model.json");
|
||||
modelLoaded = true;
|
||||
} catch (error) {
|
||||
console.error("Failed to load ML model:", error);
|
||||
// Fall back to grid-based detection
|
||||
}
|
||||
}
|
||||
|
||||
async function detectPanelsML(imageData: ImageData): Promise<Panel[]> {
|
||||
if (!modelLoaded) {
|
||||
await loadMLModel();
|
||||
}
|
||||
|
||||
if (!panelModel) {
|
||||
// Fall back to grid-based
|
||||
return detectPanelsGrid(imageData);
|
||||
}
|
||||
|
||||
// Run ML model
|
||||
const predictions = await panelModel.detect(imageData);
|
||||
|
||||
// Convert predictions to Panel format
|
||||
return predictions.map((pred: any, index: number) => ({
|
||||
id: `ml-panel-${index}`,
|
||||
x: pred.bbox.x * 100,
|
||||
y: pred.bbox.y * 100,
|
||||
width: pred.bbox.width * 100,
|
||||
height: pred.bbox.height * 100,
|
||||
reading_order: index,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user