Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ on:
push:
branches:
- main
- wildlife-reid
pull_request:
branches:
- main
- wildlife-reid

jobs:
lint:
Expand Down
9 changes: 7 additions & 2 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────
Expand Down
2 changes: 1 addition & 1 deletion __tests__/integration/wildlife/pipelineFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions __tests__/unit/services/onnxInferenceService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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([]);

Expand All @@ -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',
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,39 +56,63 @@ 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))

val output = ImageTensorModule.bitmapToNchw(
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))

val mean = doubleArrayOf(0.485, 0.456, 0.406)
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)
Expand Down
8 changes: 5 additions & 3 deletions ios/ImageTensorModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}
}

Expand Down
30 changes: 27 additions & 3 deletions ios/OffgridMobileTests/OffgridMobileTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -819,22 +819,46 @@ 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")
return
}
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() {
Expand Down
2 changes: 1 addition & 1 deletion src/screens/CaptureScreen/useCaptureFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 0 additions & 23 deletions src/screens/MatchReviewScreen.tsx

This file was deleted.

2 changes: 1 addition & 1 deletion src/services/onnxInferenceService/preprocessing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);

Expand Down
Loading