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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/moonboard-ocr/src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
*/

import { CanvasImageProcessor } from './image-processor/canvas-processor';
import { parseWithProcessor, deduplicateClimbs } from './parser';
// Import from parser-core to avoid pulling in sharp via parser.ts
import { parseWithProcessor, deduplicateClimbs } from './parser-core';
import type { ParseResult, MoonBoardClimb, DetectedHold, GridCoordinate, HoldType } from './types';

// Re-export types for consumers
Expand Down Expand Up @@ -79,5 +80,5 @@ export { deduplicateClimbs };

// Re-export the CanvasImageProcessor for advanced use cases
export { CanvasImageProcessor } from './image-processor/canvas-processor';
export { parseWithProcessor } from './parser';
export { parseWithProcessor } from './parser-core';
export type { ImageProcessor, RawPixelData, ImageMetadata, ImageRegion } from './image-processor/types';
114 changes: 114 additions & 0 deletions packages/moonboard-ocr/src/parser-core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* Core parsing functions that work in both Node.js and browser environments.
* This module does NOT import sharp or any Node.js-specific modules.
*
* For Node.js-specific functions (that use file paths), see parser.ts
*/

import { ImageProcessor } from './image-processor/types';
import { runOCR } from './core/ocr';
import { detectHoldsFromPixelData } from './core/holds';
import { calculateRegions } from './core/regions';
import { MoonBoardClimb, ParseResult, GridCoordinate } from './types';

/**
* Parse a MoonBoard screenshot using the provided ImageProcessor.
* This is the core parsing function used by both Node and browser implementations.
*/
export async function parseWithProcessor(
processor: ImageProcessor
): Promise<ParseResult> {
const warnings: string[] = [];

try {
const metadata = processor.getMetadata();
const regions = calculateRegions(metadata.width, metadata.height);

// Extract header for OCR
const ocrImageData = await processor.extractForOCR(regions.header);
const ocrResult = await runOCR(ocrImageData);
warnings.push(...ocrResult.warnings);

// Extract board region for hold detection
const boardPixels = await processor.extractRegion(regions.board);
const detectedHolds = detectHoldsFromPixelData(boardPixels, regions.board);

// Group holds by type
const startHolds: GridCoordinate[] = [];
const handHolds: GridCoordinate[] = [];
const finishHolds: GridCoordinate[] = [];

for (const hold of detectedHolds) {
switch (hold.type) {
case 'start':
startHolds.push(hold.coordinate);
break;
case 'hand':
handHolds.push(hold.coordinate);
break;
case 'finish':
finishHolds.push(hold.coordinate);
break;
}
}

// Validate we found some holds
if (startHolds.length === 0) {
warnings.push('No start holds detected');
}
if (finishHolds.length === 0) {
warnings.push('No finish holds detected');
}
if (startHolds.length + handHolds.length + finishHolds.length === 0) {
return { success: false, error: 'No holds detected in image', warnings };
}

// Build the climb object
const climb: MoonBoardClimb = {
name: ocrResult.name,
setter: ocrResult.setter,
angle: ocrResult.angle,
userGrade: ocrResult.userGrade,
setterGrade: ocrResult.setterGrade,
isBenchmark: ocrResult.isBenchmark,
holds: {
start: [...new Set(startHolds)], // Dedupe
hand: [...new Set(handHolds)],
finish: [...new Set(finishHolds)],
},
sourceFile: processor.getSourceName(),
parseWarnings: warnings.length > 0 ? warnings : undefined,
};

return { success: true, climb, warnings };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: `Failed to parse image: ${message}`, warnings };
}
}

/**
* Deduplicate climbs by their hold configuration
* Two climbs with the same holds are considered the same climb
*/
export function deduplicateClimbs(climbs: MoonBoardClimb[]): MoonBoardClimb[] {
const seen = new Map<string, MoonBoardClimb>();

for (const climb of climbs) {
// Create a unique key from sorted hold positions
const key = [
...climb.holds.start.sort(),
'|',
...climb.holds.hand.sort(),
'|',
...climb.holds.finish.sort(),
].join(',');

// Keep the first occurrence (or could prefer one with better OCR results)
if (!seen.has(key)) {
seen.set(key, climb);
}
}

return Array.from(seen.values());
}
115 changes: 10 additions & 105 deletions packages/moonboard-ocr/src/parser.ts
Original file line number Diff line number Diff line change
@@ -1,86 +1,17 @@
import path from 'path';
import { ImageProcessor } from './image-processor/types';
import { SharpImageProcessor } from './image-processor/sharp-processor';
import { runOCR } from './core/ocr';
import { detectHoldsFromPixelData } from './core/holds';
import { calculateRegions } from './core/regions';
import { MoonBoardClimb, ParseResult, GridCoordinate } from './types';

/**
* Parse a MoonBoard screenshot using the provided ImageProcessor.
* This is the core parsing function used by both Node and browser implementations.
* Node.js parsing functions for @boardsesh/moonboard-ocr
*
* This module provides file-path based APIs using Sharp for image processing.
* For browser usage, import from '@boardsesh/moonboard-ocr/browser' instead.
*/
export async function parseWithProcessor(
processor: ImageProcessor
): Promise<ParseResult> {
const warnings: string[] = [];

try {
const metadata = processor.getMetadata();
const regions = calculateRegions(metadata.width, metadata.height);

// Extract header for OCR
const ocrImageData = await processor.extractForOCR(regions.header);
const ocrResult = await runOCR(ocrImageData);
warnings.push(...ocrResult.warnings);

// Extract board region for hold detection
const boardPixels = await processor.extractRegion(regions.board);
const detectedHolds = detectHoldsFromPixelData(boardPixels, regions.board);

// Group holds by type
const startHolds: GridCoordinate[] = [];
const handHolds: GridCoordinate[] = [];
const finishHolds: GridCoordinate[] = [];

for (const hold of detectedHolds) {
switch (hold.type) {
case 'start':
startHolds.push(hold.coordinate);
break;
case 'hand':
handHolds.push(hold.coordinate);
break;
case 'finish':
finishHolds.push(hold.coordinate);
break;
}
}

// Validate we found some holds
if (startHolds.length === 0) {
warnings.push('No start holds detected');
}
if (finishHolds.length === 0) {
warnings.push('No finish holds detected');
}
if (startHolds.length + handHolds.length + finishHolds.length === 0) {
return { success: false, error: 'No holds detected in image', warnings };
}

// Build the climb object
const climb: MoonBoardClimb = {
name: ocrResult.name,
setter: ocrResult.setter,
angle: ocrResult.angle,
userGrade: ocrResult.userGrade,
setterGrade: ocrResult.setterGrade,
isBenchmark: ocrResult.isBenchmark,
holds: {
start: [...new Set(startHolds)], // Dedupe
hand: [...new Set(handHolds)],
finish: [...new Set(finishHolds)],
},
sourceFile: processor.getSourceName(),
parseWarnings: warnings.length > 0 ? warnings : undefined,
};
import path from 'path';
import { SharpImageProcessor } from './image-processor/sharp-processor';
import { MoonBoardClimb, ParseResult } from './types';
import { parseWithProcessor, deduplicateClimbs } from './parser-core';

return { success: true, climb, warnings };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: `Failed to parse image: ${message}`, warnings };
}
}
// Re-export browser-safe core functions for backward compatibility
export { parseWithProcessor, deduplicateClimbs };

/**
* Parse a single MoonBoard screenshot from file path (Node.js API).
Expand Down Expand Up @@ -116,29 +47,3 @@ export async function parseMultipleScreenshots(

return { climbs, errors };
}

/**
* Deduplicate climbs by their hold configuration
* Two climbs with the same holds are considered the same climb
*/
export function deduplicateClimbs(climbs: MoonBoardClimb[]): MoonBoardClimb[] {
const seen = new Map<string, MoonBoardClimb>();

for (const climb of climbs) {
// Create a unique key from sorted hold positions
const key = [
...climb.holds.start.sort(),
'|',
...climb.holds.hand.sort(),
'|',
...climb.holds.finish.sort(),
].join(',');

// Keep the first occurrence (or could prefer one with better OCR results)
if (!seen.has(key)) {
seen.set(key, climb);
}
}

return Array.from(seen.values());
}