From 3d276e58074ab366146045307cf04d3ecec92eda Mon Sep 17 00:00:00 2001 From: JasonWildMe Date: Thu, 23 Apr 2026 22:30:57 -0700 Subject: [PATCH 1/3] fix: correct preprocessing scale semantics for MiewID and detector path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scale` in DetectorConfig.normalize is documented as a multiplier (e.g. 1/255) per docs/EMBEDDING_PACK_FORMAT.md, but native code on both platforms divided by it instead, and the MiewID TS wrapper hardcoded scale=1.0 — so a spec-compliant pack would feed pixel*255 into normalization, and MiewID got 0-255 values fed into ImageNet mean/std that expects 0-1. - ImageTensorModule.kt + ImageTensorModule.swift: divide → multiply - preprocessing.ts: MiewID embedding wrapper passes 1/255 (was 1.0) - Updated existing native tests to encode the multiplier convention - Added a cross-platform golden parity fixture (1×1 pure-red pixel through ImageNet norm + scale=1/255) asserting identical numerical output (R≈2.249, G≈-2.036, B≈-1.804) in Kotlin and Swift - Pre-commit: skip iOS tests when xcodebuild is unavailable (mirrors the existing SwiftLint-missing pattern); macOS CI still runs them before merge Co-Authored-By: Claude Opus 4.7 (1M context) --- .husky/pre-commit | 9 +++- .../services/onnxInferenceService.test.ts | 8 ++-- .../imagetensor/ImageTensorModule.kt | 8 ++-- .../imagetensor/ImageTensorModuleTest.kt | 42 +++++++++++++++---- ios/ImageTensorModule.swift | 8 ++-- .../OffgridMobileTests.swift | 30 +++++++++++-- .../onnxInferenceService/preprocessing.ts | 2 +- 7 files changed, 82 insertions(+), 25 deletions(-) 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__/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/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', ); From 9a93bb211ae085c3381d499df20e390b1f055b16 Mon Sep 17 00:00:00 2001 From: JasonWildMe Date: Fri, 24 Apr 2026 07:02:27 -0700 Subject: [PATCH 2/3] ci: trigger on push + pull_request against wildlife-reid branch The wildlife re-ID fork lives on the long-lived `wildlife-reid` integration branch, but CI only fired for main. PRs against wildlife-reid (including #11) showed no status checks, blocking the Gemini / Codecov / SonarCloud review loop defined in CLAUDE.md. Adding wildlife-reid alongside main in both trigger filters so the integration branch gets the same quality gates as main. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) 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: From 5a1108557d10a64b3dffedf71cd748ea5d09bfad Mon Sep 17 00:00:00 2001 From: JasonWildMe Date: Fri, 24 Apr 2026 07:26:57 -0700 Subject: [PATCH 3/3] fix: extend scale correction to detector fallback + drop stub screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the Stage 0 scale semantics fix — codex 5.5 deep review and first CI run surfaced two remaining issues: 1. DEFAULT_DETECTOR_CONFIG fallback in useCaptureFlow still had the pre-fix `scale: 255` (divisor convention). With the now-corrected native multiplier math, the fallback would have produced pixel*255, identical to the bug we just fixed for MiewID. Updated to `1/255` in both the production default and the integration test fixture at pipelineFlow.test.ts. 2. src/screens/MatchReviewScreen.tsx was a 23-line stub superseded long ago by the MatchReviewScreen/ directory form, but the stub was never removed from git. Node module resolution prefers file-over-directory, so Jest in CI resolved the test's import to the stub and all 19 MatchReviewScreen tests failed (no testIDs on the stub). Works locally because the working tree had the file deleted-but-not-committed. Dropping the stub. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../integration/wildlife/pipelineFlow.test.ts | 2 +- src/screens/CaptureScreen/useCaptureFlow.ts | 2 +- src/screens/MatchReviewScreen.tsx | 23 ------------------- 3 files changed, 2 insertions(+), 25 deletions(-) delete mode 100644 src/screens/MatchReviewScreen.tsx 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/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 - - ); -};