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
63 changes: 63 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: CI

# Runs on pull requests from forks as well as branches. Nothing here needs a
# secret, so the read-only GITHUB_TOKEN on fork PRs is enough.
on:
pull_request:
push:
branches: [main]

permissions:
contents: read

concurrency:
group: ci-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true

jobs:
test:
name: test (node ${{ matrix.node }})
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
node: ['20', '22']
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ matrix.node }}
cache: npm

- run: npm ci
- run: npx jest --ci

package:
name: the npm tarball holds only what users need
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'

# A test fixture, a .env or a stray photo in the tarball would be
# published to everyone; fail instead.
- name: Check the packed file list
run: |
npm pack --dry-run --json > pack.json
node -e '
const files = require("./pack.json")[0].files.map(f => f.path);
const allowed = /^(src\/[^/]+\.js|LICENSE|README\.md|SECURITY\.md|package\.json)$/;
const bad = files.filter(f => !allowed.test(f));
console.log(files.join("\n"));
if (bad.length) { console.error("Not allowed in the package:", bad); process.exit(1); }
'
70 changes: 70 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: Release

# Publishes to npm when a GitHub Release is published.
#
# Authentication is npm trusted publishing: GitHub's OIDC token proves the
# package is built by this workflow in this repository, so no npm token is
# stored anywhere and every version carries a provenance attestation.
#
# One-time setup on npmjs.com (package settings -> Trusted publisher):
# organisation MoleCare, repository rn-scale-reference, workflow release.yml,
# environment npm.
# Trusted publishing needs a public repository and GitHub-hosted runners.

on:
release:
types: [published]

permissions:
contents: read

jobs:
verify:
name: tag matches the version, tests pass
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: npm

- name: Tag matches package.json
env:
TAG: ${{ github.event.release.tag_name }}
run: |
VERSION=$(node -p "require('./package.json').version")
if [ "$TAG" != "v$VERSION" ]; then
echo "Release tag $TAG does not match package.json version v$VERSION"
exit 1
fi

- run: npm ci
- run: npx jest --ci

publish:
name: publish to npm
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 10
environment: npm
permissions:
contents: read
id-token: write # npm trusted publishing (OIDC)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

# Node 24 ships npm 11, which trusted publishing needs (11.5.1 or newer).
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'

- run: npm ci --ignore-scripts
- run: npm publish --access public
40 changes: 40 additions & 0 deletions CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Code of Conduct

## Our pledge

We want MoleCare's open-source projects to be a straightforward, welcoming place
to contribute — for people of any background, experience level, or identity.

## Expected behaviour

- Be respectful and assume good faith
- Give feedback on the code, not the person
- Accept that maintainers may decline a change, particularly where clinical
safety is involved
- Respect the privacy of patients and contributors alike

## Unacceptable behaviour

- Harassment, personal attacks, or discriminatory language
- Publishing others' private information
- **Posting real patient images or health data** in issues, pull requests, or
discussions — this is the fastest way to be removed from the project
- Presenting this software's output as medical advice to other people

## Health-specific note

This project sits next to a health product. Contributors sometimes arrive with
personal experience of skin cancer — their own or a family member's. Be kind
about that. Equally, do not use the issue tracker to seek medical advice: we
cannot give it, and we will close such issues with a pointer to see a clinician.

## Enforcement

Report unacceptable behaviour to **info@molecare.co.uk**. Reports are handled
confidentially. Maintainers may warn, remove content, or ban a contributor
depending on severity.

## Attribution

Adapted from the [Contributor Covenant](https://www.contributor-covenant.org),
version 2.1.
60 changes: 60 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Contributing to @molecare/scale-reference

Thanks for being here. This package is a few hundred lines of pure JavaScript
with a fast test suite, so it is a good place for a first contribution.

## The one rule that is not negotiable

**This package estimates sizes. It never presents an estimate as a clinical
measurement.**

It turns pixels into millimetres using an object of known size in the same
photo. Those numbers carry real error (see "Limits" in the README). Any change
that hides that error, or presents a result as accurate enough for a medical
decision, will be declined, however good the code is.

| Fine | Not fine |
|---|---|
| A new reference object with its published size and a source | A size threshold that labels something as worrying |
| Better handling of tilt, or an uncertainty estimate | Rounding that suggests more precision than the photo holds |
| Clearer messages about why a photo can't be measured | Wording like "accurate" or "clinically validated" |

If you are unsure which side of the line a change sits on, open an issue and
ask before writing the code.

## Getting set up

```bash
git clone https://github.com/MoleCare/rn-scale-reference.git
cd rn-scale-reference
npm ci
npm test
```

You need Node 20 or newer. There is no React Native dependency at all.

## Adding a reference object

- Use the size published by the issuer (a mint, a manufacturer), and link the
source in the pull request.
- Set `exact: true` only for a **round** object. Scale comes from fitting an
ellipse, which a polygon (a 12-sided coin) does not match exactly.
- Keep keys upper case (`EUR_1`), as the tests check.

## Pull requests

- One change per pull request, with a test for the behaviour you changed.
- `npm test` passes; CI runs it on Node 20 and 22.
- Never attach a real photo of a person's skin to an issue or pull request.

## Releases

Maintainers publish to npm from a GitHub Release. The release workflow checks
that the tag matches `package.json`, runs the tests, and publishes with npm
provenance through GitHub's OIDC trusted publishing, so no npm token is stored
anywhere.

## Code of conduct

Everyone taking part is expected to follow the
[Code of Conduct](CODE_OF_CONDUCT.md).
101 changes: 78 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,47 +1,102 @@
# @molecare/scale-reference

Pixel ↔ millimetre conversion from known-diameter reference objects in frame.
Estimate real-world sizes in a photo from an object of known size in the same
frame: a calibration sticker or a coin. Give it the ellipse your detector found
around the reference, and it returns a scale, tells you when a photo can't be
used, and converts pixel lengths and areas to millimetres.

**Status:** private package under the [MoleCare](https://github.com/MoleCare) org. Not published to npm yet.
Pure JavaScript. No React Native or native dependency, no network, no data
collected. Detecting the reference in the image is up to you.

Pure arithmetic — no React Native peer dependency. Detection of the reference
ellipse in an image is out of scope.
> **Not a medical device.** The millimetres are **estimates**, not
> measurements. They have not been validated for clinical use. Do not use
> them for diagnosis or treatment decisions.

Made by [MoleCare](https://www.molecare.co.uk).

## Install

```bash
npm install @molecare/scale-reference
```

## Configure
## Use

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

// The ellipse your detector found around a 10 mm sticker.
const scale = ScaleReference.fromEllipse(
{majorAxisPx: 200, minorAxisPx: 190},
ScaleReference.REFERENCES.STICKER_10MM.diameterMm,
);

if (!scale.usable) {
showMessage(ScaleReference.explain(scale.reason)); // e.g. "too tilted"
} else {
const lengthMm = ScaleReference.toMillimetres(120, scale);
const areaMm2 = ScaleReference.toSquareMillimetres(4000, scale);
}

// Before comparing sizes across two photos:
ScaleReference.scalesAreComparable(scaleA, scaleB); // false if taken at very different distances
```

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. Photos tilted more than `maxTiltDegrees` (30° by default),
or where the reference is smaller than `minReferencePixels` (40 px), are
refused rather than measured.

## Limits

Treat every result as an estimate with an error of its own. The main sources:

- **Height difference.** The reference must lie flat at the same distance from
the camera as the thing measured. A coin resting on a curved surface, or
held above it, changes the scale.
- **Tilt.** The major-axis rule corrects for tilting the reference, but not for
the subject being at a different angle from it.
- **Lens distortion.** Phone lenses stretch the edges of the frame; keep the
reference and the subject near the centre.
- **Detection.** The result is only as good as the ellipse your detector finds.
- **Shape.** Only round references (`exact: true`) fit an ellipse exactly.

The 0.1 mm rounding in `toMillimetres` is for display, not a statement of
accuracy.

## Reference objects

| Key | Object | Diameter | `exact` |
|---|---|---|---|
| `STICKER_10MM` | 10 mm calibration sticker | 10 mm | yes |
| `GBP_1` | UK £1 (12-sided) | 23.43 mm | no |
| `GBP_2` | UK £2 | 28.4 mm | yes |
| `GBP_20P` | UK 20p (seven-sided) | 21.4 mm | no |
| `EUR_1` | €1 | 23.25 mm | yes |
| `EUR_2` | €2 | 25.75 mm | yes |
| `USD_QUARTER` | US quarter | 24.26 mm | yes |
| `USD_PENNY` | US penny | 19.05 mm | yes |

Add or replace references:

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

configure({
maxTiltDegrees: 30,
minReferencePixels: 40,
references: {
...DEFAULT_REFERENCES,
STICKER_10MM: {
label: '10mm calibration sticker',
diameterMm: 10,
exact: true,
},
CUSTOM_DOT_8MM: {label: '8 mm dot', diameterMm: 8, exact: true},
},
});

const scale = ScaleReference.fromEllipse(
{ majorAxisPx: 200, minorAxisPx: 200 },
ScaleReference.REFERENCES.STICKER_10MM.diameterMm,
);
const mm = ScaleReference.toMillimetres(120, scale);
```

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md) and the
[Code of Conduct](CODE_OF_CONDUCT.md). Security problems:
[SECURITY.md](SECURITY.md).

## License

Apache-2.0 © MoleCare LTD
Loading