diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e90da173..7b2ce25c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,9 +4,11 @@ on: push: branches: - main + - wildlife-reid pull_request: branches: - main + - wildlife-reid jobs: lint: diff --git a/.husky/pre-commit b/.husky/pre-commit index 4c9dfca8..e2505afa 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -26,8 +26,13 @@ if [ -n "$STAGED_SWIFT" ]; then echo "⚠️ SwiftLint not installed — skipping Swift lint. Install: brew install swiftlint" fi - echo "▶ iOS tests..." - npm run test:ios + if command -v xcodebuild >/dev/null 2>&1; then + echo "▶ iOS tests..." + npm run test:ios + else + echo "⚠️ xcodebuild not available (non-macOS host) — skipping iOS tests." + echo " These will run on the macOS CI runner before merge." + fi fi # ── Kotlin / Android ─────────────────────────────────────────────────────────── diff --git a/__tests__/integration/wildlife/pipelineFlow.test.ts b/__tests__/integration/wildlife/pipelineFlow.test.ts index 31141b72..16b88537 100644 --- a/__tests__/integration/wildlife/pipelineFlow.test.ts +++ b/__tests__/integration/wildlife/pipelineFlow.test.ts @@ -74,7 +74,7 @@ const DETECTOR_CONFIG = { normalize: { mean: [0, 0, 0] as [number, number, number], std: [1, 1, 1] as [number, number, number], - scale: 255, + scale: 1 / 255, }, confidenceThreshold: 0.5, nmsThreshold: 0.45, diff --git a/__tests__/unit/services/onnxInferenceService.test.ts b/__tests__/unit/services/onnxInferenceService.test.ts index e35ec7a3..44bc00ec 100644 --- a/__tests__/unit/services/onnxInferenceService.test.ts +++ b/__tests__/unit/services/onnxInferenceService.test.ts @@ -219,7 +219,7 @@ describe('preprocessImageForDetection', () => { }); describe('preprocessImageForEmbedding', () => { - it('should call ImageTensorModule with default MiewID params', async () => { + it('should call ImageTensorModule with default MiewID params (scale = 1/255 for ImageNet)', async () => { const { ImageTensorModule } = require('../../../src/services/onnxInferenceService/nativeImageTensor'); const mockData = [0.5, 0.6, 0.7]; (ImageTensorModule.imageToTensor as jest.Mock).mockResolvedValueOnce(mockData); @@ -231,13 +231,13 @@ describe('preprocessImageForEmbedding', () => { 440, 440, [0.485, 0.456, 0.406], [0.229, 0.224, 0.225], - 1.0, + 1.0 / 255.0, 'RGB', ); expect(result).toBeInstanceOf(Float32Array); }); - it('should accept custom inputSize and normalize params', async () => { + it('should accept custom inputSize and normalize params (scale still 1/255)', async () => { const { ImageTensorModule } = require('../../../src/services/onnxInferenceService/nativeImageTensor'); (ImageTensorModule.imageToTensor as jest.Mock).mockResolvedValueOnce([]); @@ -252,7 +252,7 @@ describe('preprocessImageForEmbedding', () => { 224, 224, [0.5, 0.5, 0.5], [0.5, 0.5, 0.5], - 1.0, + 1.0 / 255.0, 'RGB', ); }); diff --git a/android/app/src/main/java/ai/offgridmobile/imagetensor/ImageTensorModule.kt b/android/app/src/main/java/ai/offgridmobile/imagetensor/ImageTensorModule.kt index cb00fa71..1e43b2be 100644 --- a/android/app/src/main/java/ai/offgridmobile/imagetensor/ImageTensorModule.kt +++ b/android/app/src/main/java/ai/offgridmobile/imagetensor/ImageTensorModule.kt @@ -59,9 +59,11 @@ class ImageTensorModule(reactContext: ReactApplicationContext) : val b = (pixel and 0xFF).toDouble() // NCHW layout: channel * H * W + row * W + col - output[rIdx * h * w + i] = (r / scale - mean[0]) / std[0] - output[gIdx * h * w + i] = (g / scale - mean[1]) / std[1] - output[bIdx * h * w + i] = (b / scale - mean[2]) / std[2] + // `scale` is a multiplier per docs/EMBEDDING_PACK_FORMAT.md (e.g. 1/255 to move + // uint8 [0,255] into float [0,1] before mean/std normalization). + output[rIdx * h * w + i] = (r * scale - mean[0]) / std[0] + output[gIdx * h * w + i] = (g * scale - mean[1]) / std[1] + output[bIdx * h * w + i] = (b * scale - mean[2]) / std[2] } return output diff --git a/android/app/src/test/java/ai/offgridmobile/imagetensor/ImageTensorModuleTest.kt b/android/app/src/test/java/ai/offgridmobile/imagetensor/ImageTensorModuleTest.kt index 1f9f7dc0..92234eff 100644 --- a/android/app/src/test/java/ai/offgridmobile/imagetensor/ImageTensorModuleTest.kt +++ b/android/app/src/test/java/ai/offgridmobile/imagetensor/ImageTensorModuleTest.kt @@ -56,7 +56,8 @@ class ImageTensorModuleTest { } @Test - fun `bitmapToNchw with scale 255 normalizes to 0-1 range`() { + fun `bitmapToNchw with scale 1 over 255 normalizes to 0-1 range`() { + // Per EMBEDDING_PACK_FORMAT.md, scale is a MULTIPLIER (e.g. 1/255) applied before mean/std. val bmp = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) bmp.setPixel(0, 0, Color.rgb(255, 128, 0)) @@ -64,16 +65,16 @@ class ImageTensorModuleTest { bmp, 1, 1, doubleArrayOf(0.0, 0.0, 0.0), doubleArrayOf(1.0, 1.0, 1.0), - 255.0, false, + 1.0 / 255.0, false, ) - assertEquals(1.0, output[0], 0.01) // R: 255/255 - assertEquals(128.0 / 255.0, output[1], 0.01) // G: 128/255 - assertEquals(0.0, output[2], 0.01) // B: 0/255 + assertEquals(1.0, output[0], 0.01) // R: 255 * 1/255 + assertEquals(128.0 / 255.0, output[1], 0.01) // G: 128 * 1/255 + assertEquals(0.0, output[2], 0.01) // B: 0 * 1/255 bmp.recycle() } @Test - fun `bitmapToNchw with ImageNet normalization`() { + fun `bitmapToNchw with ImageNet normalization applies scale as multiplier`() { val bmp = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) bmp.setPixel(0, 0, Color.rgb(128, 128, 128)) @@ -81,14 +82,37 @@ class ImageTensorModuleTest { val std = doubleArrayOf(0.229, 0.224, 0.225) val output = ImageTensorModule.bitmapToNchw( - bmp, 1, 1, mean, std, 255.0, false, + bmp, 1, 1, mean, std, 1.0 / 255.0, false, ) - // (128/255 - 0.485) / 0.229 ≈ 0.0682 - val expected = (128.0 / 255.0 - 0.485) / 0.229 + // Formula: (pixel * scale - mean) / std → (128 * 1/255 - 0.485) / 0.229 ≈ 0.0682 + val expected = (128.0 * (1.0 / 255.0) - 0.485) / 0.229 assertEquals(expected, output[0], 0.001) bmp.recycle() } + @Test + fun `bitmapToNchw MiewID parity fixture — solid red 255 with ImageNet norm`() { + // Golden parity fixture: this is the NumPy-computed expected output for a 1×1 pure-red + // pixel passed through MiewID's preprocessing path (ImageNet mean/std, scale = 1/255). + // R: (255/255 - 0.485) / 0.229 = (1 - 0.485) / 0.229 ≈ 2.2489... + // G: (0 - 0.456) / 0.224 ≈ -2.0357... + // B: (0 - 0.406) / 0.225 ≈ -1.8044... + // If any of these drift, MiewID embeddings will be garbage. Fail loudly. + val bmp = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + bmp.setPixel(0, 0, Color.RED) + + val output = ImageTensorModule.bitmapToNchw( + bmp, 1, 1, + doubleArrayOf(0.485, 0.456, 0.406), + doubleArrayOf(0.229, 0.224, 0.225), + 1.0 / 255.0, false, + ) + assertEquals(2.2489083, output[0], 1e-4) + assertEquals(-2.0357143, output[1], 1e-4) + assertEquals(-1.8044444, output[2], 1e-4) + bmp.recycle() + } + @Test fun `bitmapToNchw BGR swaps channels 0 and 2`() { val bmp = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) diff --git a/ios/ImageTensorModule.swift b/ios/ImageTensorModule.swift index 2d5e1088..6b7a1cc7 100644 --- a/ios/ImageTensorModule.swift +++ b/ios/ImageTensorModule.swift @@ -185,9 +185,11 @@ class ImageTensorModule: NSObject { let b = Double(pixelData[pixelOffset + 2]) let i = row * w + col - output[rIdx * h * w + i] = (r / scale - mean[0]) / std[0] - output[gIdx * h * w + i] = (g / scale - mean[1]) / std[1] - output[bIdx * h * w + i] = (b / scale - mean[2]) / std[2] + // `scale` is a multiplier per docs/EMBEDDING_PACK_FORMAT.md (e.g. 1/255 to move + // uint8 [0,255] into float [0,1] before mean/std normalization). + output[rIdx * h * w + i] = (r * scale - mean[0]) / std[0] + output[gIdx * h * w + i] = (g * scale - mean[1]) / std[1] + output[bIdx * h * w + i] = (b * scale - mean[2]) / std[2] } } diff --git a/ios/OffgridMobileTests/OffgridMobileTests.swift b/ios/OffgridMobileTests/OffgridMobileTests.swift index af515d21..3737d029 100644 --- a/ios/OffgridMobileTests/OffgridMobileTests.swift +++ b/ios/OffgridMobileTests/OffgridMobileTests.swift @@ -819,7 +819,8 @@ final class ImageTensorModuleTests: XCTestCase { XCTAssertEqual(output[2], 255.0, accuracy: 1.0) // R } - func testExtractNchwWithScale() { + func testExtractNchwWithScaleMultiplier() { + // Per EMBEDDING_PACK_FORMAT.md, `scale` is a multiplier (e.g. 1/255) applied before mean/std. let image = createTestImage(width: 1, height: 1, color: .white) guard let cgImage = image.cgImage else { XCTFail("Could not get CGImage") @@ -827,14 +828,37 @@ final class ImageTensorModuleTests: XCTestCase { } let output = ImageTensorModule.extractNchw( from: cgImage, width: 1, height: 1, - mean: [0, 0, 0], std: [1, 1, 1], scale: 255.0, bgr: false + mean: [0, 0, 0], std: [1, 1, 1], scale: 1.0 / 255.0, bgr: false )! - // 255/255 = 1.0 + // 255 * 1/255 = 1.0 XCTAssertEqual(output[0], 1.0, accuracy: 0.01) XCTAssertEqual(output[1], 1.0, accuracy: 0.01) XCTAssertEqual(output[2], 1.0, accuracy: 0.01) } + func testExtractNchwMiewIDParityFixtureSolidRed() { + // Golden parity fixture — must match the Kotlin and Python references for a 1×1 pure-red + // pixel through MiewID's preprocessing path (ImageNet mean/std, scale = 1/255). + // R: (255/255 - 0.485) / 0.229 ≈ 2.2489... + // G: (0 - 0.456) / 0.224 ≈ -2.0357... + // B: (0 - 0.406) / 0.225 ≈ -1.8044... + let image = createTestImage(width: 1, height: 1, color: .red) + guard let cgImage = image.cgImage else { + XCTFail("Could not get CGImage") + return + } + let output = ImageTensorModule.extractNchw( + from: cgImage, width: 1, height: 1, + mean: [0.485, 0.456, 0.406], + std: [0.229, 0.224, 0.225], + scale: 1.0 / 255.0, + bgr: false + )! + XCTAssertEqual(output[0], 2.2489083, accuracy: 1e-3) + XCTAssertEqual(output[1], -2.0357143, accuracy: 1e-3) + XCTAssertEqual(output[2], -1.8044444, accuracy: 1e-3) + } + // -- cropImage tests -- func testCropImageCreatesFile() { diff --git a/src/screens/CaptureScreen/useCaptureFlow.ts b/src/screens/CaptureScreen/useCaptureFlow.ts index 298749f0..d50ab8b9 100644 --- a/src/screens/CaptureScreen/useCaptureFlow.ts +++ b/src/screens/CaptureScreen/useCaptureFlow.ts @@ -37,7 +37,7 @@ const DEFAULT_DETECTOR_CONFIG: DetectorConfig = { inputSize: [640, 640], inputChannels: 3, channelOrder: 'RGB', - normalize: { mean: [0, 0, 0], std: [1, 1, 1], scale: 255 }, + normalize: { mean: [0, 0, 0], std: [1, 1, 1], scale: 1 / 255 }, confidenceThreshold: 0.25, nmsThreshold: 0.45, maxDetections: 100, diff --git a/src/screens/MatchReviewScreen.tsx b/src/screens/MatchReviewScreen.tsx deleted file mode 100644 index f80bee16..00000000 --- a/src/screens/MatchReviewScreen.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; -import { StyleSheet, Text } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { useTheme } from '../theme'; - -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, -}); - -export const MatchReviewScreen: React.FC = () => { - const { colors } = useTheme(); - return ( - - Match Review - - ); -}; diff --git a/src/services/onnxInferenceService/preprocessing.ts b/src/services/onnxInferenceService/preprocessing.ts index ff6382a1..9a7b196b 100644 --- a/src/services/onnxInferenceService/preprocessing.ts +++ b/src/services/onnxInferenceService/preprocessing.ts @@ -65,7 +65,7 @@ export async function preprocessImageForEmbedding( height, norm.mean, norm.std, - 1.0, // Embedding models expect pixel values in [0, 255] divided by 1.0 + 1.0 / 255.0, // MiewID (and ImageNet norm in general) expects inputs in [0,1] before mean/std 'RGB', );