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
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ decision, will be declined, however good the code is.
If you are unsure which side of the line a change sits on, open an issue and
ask before writing the code.

## Stateless by design

The package keeps no state and has no global settings. Every setting is an
argument, with its default in `src/defaults.js`, and module scope holds frozen
constants only (a test fails on a module-level `let` or `var`). Please don't
add a `configure()`, a cache or a singleton; add an option instead.

## Getting set up

```bash
Expand Down
44 changes: 36 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,34 @@ short axis does not. Photos tilted more than `maxTiltDegrees` (30° by default),
or where the reference is smaller than `minReferencePixels` (40 px), are
refused rather than measured.

## No state, no global settings

Every method is a pure function of its arguments. The package keeps nothing
between calls and has no global configuration, so two parts of an app (or two
libraries) can use different settings without affecting each other. Pass
settings with the call:

```js
import {ScaleReference, DEFAULT_OPTIONS} from '@molecare/scale-reference';

const options = {...DEFAULT_OPTIONS, maxTiltDegrees: 20};
const scale = ScaleReference.fromEllipse(ellipse, diameterMm, options);
```

An unknown option, or one that is not a positive number, throws a `TypeError`
rather than being ignored.

Messages from `explain` are plain English. To use your own wording or a
translation, pass it in, keyed by reason, with `default` for anything else:

```js
ScaleReference.explain(scale.reason, {
reference_too_small: t('scale.tooSmall'),
reference_too_tilted: t('scale.tooTilted'),
default: t('scale.cannotMeasure'),
});
```

## Limits

Treat every result as an estimate with an error of its own. The main sources:
Expand Down Expand Up @@ -78,17 +106,17 @@ accuracy.
| `USD_QUARTER` | US quarter | 24.26 mm | yes |
| `USD_PENNY` | US penny | 19.05 mm | yes |

Add or replace references:
The table is frozen and nothing in the package reads it for you. It is only a
convenience: `fromEllipse` takes any diameter, so keep your own references in
your app:

```js
import {configure, DEFAULT_REFERENCES} from '@molecare/scale-reference';
const MY_REFERENCES = {
...DEFAULT_REFERENCES,
CUSTOM_DOT_8MM: {label: '8 mm dot', diameterMm: 8, exact: true},
};

configure({
references: {
...DEFAULT_REFERENCES,
CUSTOM_DOT_8MM: {label: '8 mm dot', diameterMm: 8, exact: true},
},
});
ScaleReference.fromEllipse(ellipse, MY_REFERENCES.CUSTOM_DOT_8MM.diameterMm);
```

## Contributing
Expand Down
64 changes: 61 additions & 3 deletions __tests__/ScaleReference.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,14 +199,72 @@ describe('the reference table', () => {
});

it('cannot be changed for everyone by mutating a default entry', () => {
// getConfig() copies the catalog, but the entries were shared objects: one
// module changing a diameter changed it for the whole app.
const {DEFAULT_REFERENCES} = require('../src/config');
const {DEFAULT_REFERENCES} = require('../src/defaults');
expect(Object.isFrozen(DEFAULT_REFERENCES)).toBe(true);
expect(Object.isFrozen(DEFAULT_REFERENCES.STICKER_10MM)).toBe(true);
expect(() => {
'use strict';
ScaleReference.REFERENCES.STICKER_10MM.diameterMm = 99;
}).toThrow(TypeError);
expect(() => {
'use strict';
ScaleReference.REFERENCES.CUSTOM = {label: 'x', diameterMm: 5, exact: true};
}).toThrow(TypeError);
expect(ScaleReference.REFERENCES.STICKER_10MM.diameterMm).toBe(10);
});
});

describe('no state: settings go with each call', () => {
it('uses the options of one call without changing the next', () => {
// A 30 px sticker is refused by default (40 px minimum).
const relaxed = ScaleReference.fromEllipse(flat(30), STICKER, {minReferencePixels: 20});
const plain = ScaleReference.fromEllipse(flat(30), STICKER);

expect(relaxed.usable).toBe(true);
expect(plain.reason).toBe('reference_too_small');
});

it('lets a caller choose its own tilt limit', () => {
expect(ScaleReference.fromEllipse(tilted(200, 25), STICKER, {maxTiltDegrees: 20}).reason).toBe(
'reference_too_tilted',
);
expect(ScaleReference.fromEllipse(tilted(200, 25), STICKER).usable).toBe(true);
});

it('refuses a mistyped or meaningless option instead of ignoring it', () => {
expect(() => ScaleReference.fromEllipse(flat(200), STICKER, {maxTilt: 10})).toThrow(/maxTilt/);
expect(() => ScaleReference.fromEllipse(flat(200), STICKER, {minReferencePixels: 0})).toThrow(
TypeError,
);
expect(() =>
ScaleReference.fromEllipse(flat(200), STICKER, {maxTiltDegrees: '30'}),
).toThrow(TypeError);
});

it('takes the text of an explanation from the caller when given', () => {
const messages = {reference_too_small: 'Trop petit.', default: 'Impossible.'};

expect(ScaleReference.explain('reference_too_small', messages)).toBe('Trop petit.');
expect(ScaleReference.explain('something_else', messages)).toBe('Impossible.');
expect(ScaleReference.explain('reference_too_small')).toMatch(/too small/);
});

it('exports no global configuration', () => {
const api = require('../src');
expect(api.configure).toBeUndefined();
expect(api.getConfig).toBeUndefined();
expect(api.resetConfig).toBeUndefined();
expect(Object.isFrozen(api.DEFAULT_OPTIONS)).toBe(true);
});

it('has no module-level variables in its source', () => {
// The rule that keeps it stateless: module scope holds constants only.
const fs = require('fs');
const path = require('path');
const srcDir = path.join(__dirname, '..', 'src');
for (const file of fs.readdirSync(srcDir).filter(f => f.endsWith('.js'))) {
const source = fs.readFileSync(path.join(srcDir, file), 'utf8');
expect([file, /^(let|var)\s/m.test(source)]).toEqual([file, false]);
}
});
});
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@molecare/scale-reference",
"version": "0.1.0",
"version": "0.2.0",
"description": "Estimate sizes in a photo from a reference object of known size (sticker or coin): pixel to millimetre scale, tilt checks and comparability. Pure JavaScript.",
"main": "src/index.js",
"license": "Apache-2.0",
Expand Down
63 changes: 45 additions & 18 deletions src/ScaleReference.js
Original file line number Diff line number Diff line change
@@ -1,36 +1,34 @@
import {getConfig} from './config';
import {DEFAULT_OPTIONS, DEFAULT_REFERENCES} from './defaults';

/**
* Turning pixels into millimetres, given something of known size in the frame.
*
* Scale comes from the major axis alone: a circle photographed off-perpendicular
* projects to an ellipse whose major axis keeps the true diameter.
*
* Stateless: every method is a pure function of its arguments. Settings are
* passed per call; there is no global configuration to change.
*/
export default class ScaleReference {
/**
* Diameters in millimetres of known reference objects.
* Override the whole catalog via configure({ references }).
*/
static get REFERENCES() {
return getConfig().references;
}
/** Known reference objects (frozen). Pass any diameter you like instead. */
static REFERENCES = DEFAULT_REFERENCES;

static get MAX_TILT_DEGREES() {
return getConfig().maxTiltDegrees;
}
static MAX_TILT_DEGREES = DEFAULT_OPTIONS.maxTiltDegrees;

static get MIN_REFERENCE_PIXELS() {
return getConfig().minReferencePixels;
}
static MIN_REFERENCE_PIXELS = DEFAULT_OPTIONS.minReferencePixels;

/**
* Scale from a detected reference.
*
* @param {{majorAxisPx: number, minorAxisPx: number}} ellipse
* @param {number} diameterMm
* @param {{maxTiltDegrees?: number, minReferencePixels?: number}} [options]
* defaults in DEFAULT_OPTIONS
* @returns {{mmPerPixel: number, tiltDegrees: number, usable: boolean, reason: string|null}|null}
* @throws {TypeError} for an unknown option or one that is not a positive number
*/
static fromEllipse(ellipse, diameterMm) {
static fromEllipse(ellipse, diameterMm, options) {
const {maxTiltDegrees, minReferencePixels} = ScaleReference._options(options);
const major = ScaleReference._positive(ellipse && ellipse.majorAxisPx);
const minor = ScaleReference._positive(ellipse && ellipse.minorAxisPx);
const mm = ScaleReference._positive(diameterMm);
Expand All @@ -47,9 +45,9 @@ export default class ScaleReference {
(Math.acos(Math.min(1, shortAxis / longAxis)) * 180) / Math.PI;

let reason = null;
if (longAxis < ScaleReference.MIN_REFERENCE_PIXELS) {
if (longAxis < minReferencePixels) {
reason = 'reference_too_small';
} else if (tiltDegrees > ScaleReference.MAX_TILT_DEGREES) {
} else if (tiltDegrees > maxTiltDegrees) {
reason = 'reference_too_tilted';
}

Expand Down Expand Up @@ -104,7 +102,20 @@ export default class ScaleReference {
return (larger - smaller) / larger <= tolerance;
}

static explain(reason) {
/**
* A plain English explanation of a refusal.
*
* @param {string} reason - `reason` from fromEllipse
* @param {Object<string, string>} [messages] - your own text (for example a
* translation) keyed by reason; `default` covers any other reason
*/
static explain(reason, messages) {
if (messages && typeof messages[reason] === 'string') {
return messages[reason];
}
if (messages && typeof messages.default === 'string') {
return messages.default;
}
switch (reason) {
case 'reference_too_small':
return 'The reference object is too small in the photo to measure from. Move closer, or place it nearer the subject.';
Expand All @@ -115,6 +126,22 @@ export default class ScaleReference {
}
}

static _options(options) {
if (options === undefined || options === null) {
return DEFAULT_OPTIONS;
}
for (const key of Object.keys(options)) {
if (!Object.prototype.hasOwnProperty.call(DEFAULT_OPTIONS, key)) {
throw new TypeError(`Unknown ScaleReference option "${key}"`);
}
const value = options[key];
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
throw new TypeError(`ScaleReference option "${key}" must be a positive number`);
}
}
return {...DEFAULT_OPTIONS, ...options};
}

static _positive(value) {
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) && n > 0 ? n : null;
Expand Down
71 changes: 0 additions & 71 deletions src/config.js

This file was deleted.

30 changes: 30 additions & 0 deletions src/defaults.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Constants only. Nothing in this package keeps state: settings are passed to
* each call, and these are the values used when a caller passes none.
*
* `exact` means the object is round, so an ellipse fitted to it has the
* published diameter as its major axis. A polygon (the 12-sided £1, the
* seven-sided 20p) does not fit an ellipse exactly, so its scale is rougher.
* Diameters are the issuers' published figures.
*/

const reference = (label, diameterMm, exact) =>
Object.freeze({label, diameterMm, exact});

export const DEFAULT_REFERENCES = Object.freeze({
STICKER_10MM: reference('10mm calibration sticker', 10, true),
// Royal Mint: 12-sided, 23.43 mm.
GBP_1: reference('UK £1', 23.43, false),
GBP_2: reference('UK £2', 28.4, true),
// Royal Mint: seven-sided equal-width curve, 21.4 mm.
GBP_20P: reference('UK 20p', 21.4, false),
EUR_1: reference('€1', 23.25, true),
EUR_2: reference('€2', 25.75, true),
USD_QUARTER: reference('US quarter', 24.26, true),
USD_PENNY: reference('US penny', 19.05, true),
});

export const DEFAULT_OPTIONS = Object.freeze({
maxTiltDegrees: 30,
minReferencePixels: 40,
});
2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export {configure, getConfig, resetConfig, DEFAULT_REFERENCES} from './config';
export {DEFAULT_OPTIONS, DEFAULT_REFERENCES} from './defaults';
export {default as ScaleReference} from './ScaleReference';
export {default} from './ScaleReference';