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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to this package are recorded here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
[Semantic Versioning](https://semver.org/).

## Unreleased

### Added

- `examples/`: a tap-to-measure React Native screen, a detector-to-millimetres
helper, and a two-photo comparison that declines different distances. CI
typechecks and runs them. Not part of the published package.

## 1.0.0

First public release. Breaking changes from 0.x, which was never published.
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ Scale comes from the ellipse's **major axis**. A round object photographed at
an angle looks oval, but its long axis still spans the true diameter; the
short axis does not.

### Complete examples

[`examples/`](examples/) has copy-paste starting points, typechecked and run in
CI so they stay in step with the API:

- [`TapToMeasure.tsx`](examples/TapToMeasure.tsx): a React Native screen that
measures with four taps, no detector needed.
- [`fromDetector.ts`](examples/fromDetector.ts): your detector's ellipse to
lengths and areas, with a stricter tilt limit.
- [`compareTwoPhotos.ts`](examples/compareTwoPhotos.ts): check two photos were
taken at a similar distance before showing both sizes.

## API

| Function | Returns |
Expand Down
93 changes: 93 additions & 0 deletions __tests__/examples.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// The examples in examples/ are documentation; this runs the plain ones so a
// change that breaks their numbers fails here, not in someone's app.
import {
compare,
describe as describeResult,
} from '../examples/compareTwoPhotos';
import { example, measure } from '../examples/fromDetector';

describe('examples/fromDetector', () => {
it('turns a 200 px sticker into millimetres', () => {
expect(example).toMatchObject({
ok: true,
lengthMm: 6,
areaMm2: 27.5,
roughScale: false,
});
// 200 x 190 px is a slight tilt, well inside the 20掳 limit.
expect(example.ok && example.tiltDegrees).toBeGreaterThan(0);
expect(example.ok && example.tiltDegrees).toBeLessThan(20);
});

it('explains a refusal instead of measuring', () => {
const result = measure({
reference: { majorAxisPx: 200, minorAxisPx: 100 }, // 60掳 tilt
referenceKind: 'STICKER_10MM',
subjectLengthPx: 120,
subjectAreaPx: 11000,
});
expect(result.ok).toBe(false);
});

it('flags polygon coins as a rough scale', () => {
const result = measure({
reference: { majorAxisPx: 400, minorAxisPx: 395 },
referenceKind: 'GBP_1',
subjectLengthPx: 100,
subjectAreaPx: 5000,
});
expect(result).toMatchObject({ ok: true, roughScale: true });
});

it('reports a missing size', () => {
const result = measure({
reference: { majorAxisPx: 200, minorAxisPx: 200 },
referenceKind: 'STICKER_10MM',
subjectLengthPx: 0,
subjectAreaPx: 0,
});
expect(result).toEqual({
ok: false,
message: 'The detector found no size to measure.',
});
});
});

describe('examples/compareTwoPhotos', () => {
const photo = (majorAxisPx: number, subjectLengthPx: number) => ({
takenAt: '2026-03-01',
reference: { majorAxisPx, minorAxisPx: majorAxisPx },
subjectLengthPx,
});

it('shows both estimates when the distances are similar', () => {
const result = compare(photo(200, 120), photo(210, 130));
expect(result).toEqual({ kind: 'comparable', earlierMm: 6, laterMm: 6.2 });
expect(describeResult(result)).toBe('Estimates: 6.0 mm then 6.2 mm.');
});

it('declines to compare photos taken at very different distances', () => {
const result = compare(photo(100, 60), photo(300, 180));
expect(result).toEqual({ kind: 'different_distance' });
expect(describeResult(result)).toMatch(/different distances/);
});

it('says when a photo cannot be measured', () => {
const result = compare(photo(10, 60), photo(200, 120));
expect(result).toEqual({ kind: 'not_measurable' });
expect(describeResult(result)).toMatch(/cannot be measured/);
});

it('never judges a change', () => {
const texts = [
describeResult({ kind: 'comparable', earlierMm: 4, laterMm: 9 }),
describeResult({ kind: 'different_distance' }),
describeResult({ kind: 'not_measurable' }),
];
texts.forEach((text) => {
expect(text).not.toMatch(
/grow|grown|bigger|worse|concern|risk|see a doctor/i
);
});
});
});
13 changes: 13 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Examples

Complete, copy-paste starting points. CI typechecks every file here against
the package, so they stay in step with the API.

| File | Shows |
| -------------------------------------------- | ---------------------------------------------------------------------------------- |
| [`TapToMeasure.tsx`](TapToMeasure.tsx) | A React Native screen that measures with four taps, no detector needed |
| [`fromDetector.ts`](fromDetector.ts) | Turning your detector's ellipse into lengths and areas, with a stricter tilt limit |
| [`compareTwoPhotos.ts`](compareTwoPhotos.ts) | Checking two photos were taken at a similar distance before showing both sizes |

Every result is an estimate and is shown as one. The examples report numbers
only and never say whether a size or a change matters.
95 changes: 95 additions & 0 deletions examples/TapToMeasure.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Measure with taps, no detector needed.
*
* The person taps the two edges of the sticker (or coin) in the photo, then
* the two ends of what they want to measure. The distance between the first
* two taps is the reference's diameter in pixels; the second two give the
* length to convert. Always shown as an estimate.
*/
import { useState } from 'react';
import {
Button,
Image,
Pressable,
Text,
View,
type GestureResponderEvent,
} from 'react-native';
import {
REFERENCE_OBJECTS,
explainRefusal,
scaleFromEllipse,
toMillimetres,
} from '@molecare/scale-reference';

interface Point {
x: number;
y: number;
}

const distance = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y);

const PROMPTS = [
'Tap one edge of the sticker',
'Tap the opposite edge of the sticker',
'Tap one end of what you want to measure',
'Tap the other end',
];

export function TapToMeasure({ photoUri }: { photoUri: string }) {
const [taps, setTaps] = useState<Point[]>([]);

const onTap = (event: GestureResponderEvent) => {
if (taps.length >= 4) return;
const { locationX, locationY } = event.nativeEvent;
setTaps([...taps, { x: locationX, y: locationY }]);
};

let result: string | null = null;
const [a, b, c, d] = taps;
if (a && b && c && d) {
// Taps on a round sticker give its diameter; a tapped width has no tilt
// information, so the major and minor axes are the same.
const referencePx = distance(a, b);
const scale = scaleFromEllipse(
{ majorAxisPx: referencePx, minorAxisPx: referencePx },
REFERENCE_OBJECTS.STICKER_10MM.diameterMm
);
if (!scale.usable) {
// Your own words, or translations, keyed by reason.
result = explainRefusal(scale.reason, {
reference_too_small:
'The sticker is too small in this photo. Move the camera closer.',
default: 'This photo cannot be measured. Try another one.',
});
} else {
const mm = toMillimetres(distance(c, d), scale);
result =
mm === null
? 'Tap two different points.'
: `About ${mm.toFixed(1)} mm (an estimate)`;
}
}

return (
<View>
<Pressable
onPress={onTap}
accessibilityLabel="Photo to measure"
accessibilityHint={PROMPTS[taps.length] ?? 'Measurement done'}
>
<Image
source={{ uri: photoUri }}
style={{ width: '100%', aspectRatio: 1 }}
/>
</Pressable>
<Text accessibilityRole="alert">{result ?? PROMPTS[taps.length]}</Text>
<Button
title="Start again"
onPress={() => {
setTaps([]);
}}
/>
</View>
);
}
50 changes: 50 additions & 0 deletions examples/compareTwoPhotos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Before putting two sizes side by side, check that the photos were taken at
* a similar distance. If not, say so, and don't show a difference at all.
*
* This reports numbers only. It never says whether a change matters; that is
* for the person, and anyone they choose to show.
*/
import {
REFERENCE_OBJECTS,
scaleFromEllipse,
scalesAreComparable,
toMillimetres,
type Ellipse,
} from '@molecare/scale-reference';

export interface Photo {
takenAt: string;
reference: Ellipse;
subjectLengthPx: number;
}

export type Comparison =
| { kind: 'comparable'; earlierMm: number; laterMm: number }
| { kind: 'different_distance' }
| { kind: 'not_measurable' };

export function compare(earlier: Photo, later: Photo): Comparison {
const diameterMm = REFERENCE_OBJECTS.STICKER_10MM.diameterMm;
const a = scaleFromEllipse(earlier.reference, diameterMm);
const b = scaleFromEllipse(later.reference, diameterMm);
const earlierMm = toMillimetres(earlier.subjectLengthPx, a);
const laterMm = toMillimetres(later.subjectLengthPx, b);
if (earlierMm === null || laterMm === null) return { kind: 'not_measurable' };
// Scales more than 25% apart mean very different distances, where lens
// distortion alone can change the numbers.
if (!scalesAreComparable(a, b)) return { kind: 'different_distance' };
return { kind: 'comparable', earlierMm, laterMm };
}

/** Plain words for each result. Replace with your own or your translations. */
export function describe(result: Comparison): string {
switch (result.kind) {
case 'comparable':
return `Estimates: ${result.earlierMm.toFixed(1)} mm then ${result.laterMm.toFixed(1)} mm.`;
case 'different_distance':
return 'These photos were taken at different distances, so their sizes are not compared.';
case 'not_measurable':
return 'One of these photos cannot be measured.';
}
}
66 changes: 66 additions & 0 deletions examples/fromDetector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* With a detector: your ML model or OpenCV step finds the ellipse around the
* reference, and this turns it into something ready to show.
*
* Nothing here is React Native specific; it runs anywhere, including on a
* server or in a worker.
*/
import {
REFERENCE_OBJECTS,
explainRefusal,
scaleFromEllipse,
toMillimetres,
toSquareMillimetres,
type Ellipse,
type ReferenceKey,
} from '@molecare/scale-reference';

/** What your detector returns; the names are yours. */
export interface Detection {
reference: Ellipse;
referenceKind: ReferenceKey;
subjectLengthPx: number;
subjectAreaPx: number;
}

export type Measurement =
| {
ok: true;
lengthMm: number;
areaMm2: number;
tiltDegrees: number;
roughScale: boolean;
}
| { ok: false; message: string };

export function measure(detection: Detection): Measurement {
const reference = REFERENCE_OBJECTS[detection.referenceKind];
// Stricter than the default 30掳, because this app shows areas too.
const scale = scaleFromEllipse(detection.reference, reference.diameterMm, {
maxTiltDegrees: 20,
});
if (!scale.usable) {
return { ok: false, message: explainRefusal(scale.reason) };
}
const lengthMm = toMillimetres(detection.subjectLengthPx, scale);
const areaMm2 = toSquareMillimetres(detection.subjectAreaPx, scale);
if (lengthMm === null || areaMm2 === null) {
return { ok: false, message: 'The detector found no size to measure.' };
}
return {
ok: true,
lengthMm,
areaMm2,
tiltDegrees: scale.tiltDegrees,
// Polygon coins (the 12-sided 拢1, the 20p) don't fit an ellipse exactly.
roughScale: !reference.exact,
};
}

// For example, a detector that found a 10 mm sticker 200 px wide:
export const example = measure({
reference: { majorAxisPx: 200, minorAxisPx: 190 },
referenceKind: 'STICKER_10MM',
subjectLengthPx: 120,
subjectAreaPx: 11000,
});
27 changes: 27 additions & 0 deletions examples/react-native.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Just enough of React Native's types for the examples to typecheck without
// installing React Native itself. Apps use the real 'react-native' types.
declare module 'react-native' {
import type { ComponentType, ReactNode } from 'react';

export interface GestureResponderEvent {
nativeEvent: { locationX: number; locationY: number };
}
export const View: ComponentType<{ style?: object; children?: ReactNode }>;
export const Text: ComponentType<{
style?: object;
children?: ReactNode;
accessibilityRole?: 'header' | 'text' | 'alert';
}>;
export const Pressable: ComponentType<{
onPress?: (event: GestureResponderEvent) => void;
accessibilityLabel?: string;
accessibilityHint?: string;
children?: ReactNode;
}>;
export const Image: ComponentType<{
source: { uri: string };
style?: object;
accessibilityLabel?: string;
}>;
export const Button: ComponentType<{ title: string; onPress: () => void }>;
}
10 changes: 10 additions & 0 deletions examples/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": "..",
"jsx": "react-jsx",
"types": [],
"paths": { "@molecare/scale-reference": ["../src/index.ts"] }
},
"include": ["*.ts", "*.tsx"]
}
Loading
Loading