From 93042204233677e99f2aae62148a2a43010e4b95 Mon Sep 17 00:00:00 2001 From: Yauhen Bichel Date: Sun, 13 Sep 2026 09:44:14 +0100 Subject: [PATCH] Prepare scale-reference for open source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes found in the pre-publish review, and what a public package needs. Fixes: - The UK £1 was listed at 23.03 mm; the Royal Mint gives 23.43 mm. It is 12-sided and the 20p is seven-sided, so an ellipse fit does not match them exactly: both are now `exact: false`. The round coins are unchanged. - Default reference entries were shared objects: changing one diameter changed it for the whole app. Each entry is frozen now. - Neutral wording ("square to the subject"). Public-package pieces: - README rewritten: millimetres are estimates, not measurements, and are not validated for clinical use; a Limits section lists the error sources (height difference, tilt, lens distortion, detection, shape); the reference table with sizes and `exact`. - CONTRIBUTING (the estimate-not-measurement rule, how to add a reference with its source), CODE_OF_CONDUCT, SECURITY for a public repository. - publishConfig.access public; neutral keywords. - CI (Node 20 and 22, tarball file check) and a release workflow that publishes with npm trusted publishing. Actions pinned by commit SHA. Tests: 19 (was 17); both new cases fail on the old table. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 63 +++++++++++++++++++ .github/workflows/release.yml | 70 +++++++++++++++++++++ CODE_OF_CONDUCT.md | 40 ++++++++++++ CONTRIBUTING.md | 60 ++++++++++++++++++ README.md | 101 ++++++++++++++++++++++++------- SECURITY.md | 34 ++++++++--- __tests__/ScaleReference.test.js | 24 ++++++++ package.json | 11 ++-- src/ScaleReference.js | 6 +- src/config.js | 30 +++++---- 10 files changed, 388 insertions(+), 51 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4ea4627 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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); } + ' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..08a46b9 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..128e10c --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a266c67 --- /dev/null +++ b/CONTRIBUTING.md @@ -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). diff --git a/README.md b/README.md index b27b24d..bd2252e 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,18 @@ # @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 @@ -13,35 +20,83 @@ ellipse in an image is out of scope. 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 diff --git a/SECURITY.md b/SECURITY.md index 907deea..9b783ee 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,17 +1,33 @@ # Security Policy -## Supported versions +## Reporting a vulnerability -Security fixes are applied to the latest release on `main`. +**Please do not open a public GitHub issue for security problems.** -## Reporting a vulnerability +Email **info@molecare.co.uk**, or open a private +[security advisory](https://github.com/MoleCare/rn-scale-reference/security/advisories/new) +on this repository, with: + +- what the issue is and where in the code it lives +- how to reproduce it +- what an attacker could do with it + +You should get an acknowledgement within **3 working days**. We will tell you +when a fix is released and credit you in the release notes, unless you would +rather we did not. + +## Supported versions + +Security fixes go into the latest release. -Email **security@molecare.co.uk** (or open a private GitHub Security Advisory on this repo). +## Scope -Do **not** open public issues for vulnerabilities. +This package is pure arithmetic: it reads no files, makes no network calls and +stores nothing. In scope are bugs that make it return a confident number where +it should refuse (a wrong result that looks right), and anything reachable in +its dependencies. -## Publishing rules for this org +Out of scope here (but still worth telling us about at the same address): the +MoleCare apps and API. -- Never commit API keys, tokens, JWTs, `.env`, Firebase plists, Mapbox secrets, or patient/clinical images. -- Prefer injectable config over hardcoded product branding in reference catalogs. -- This repository is **private** until an explicit public-release checklist is completed. +Never include a real photo of a person, or any health data, in a report. diff --git a/__tests__/ScaleReference.test.js b/__tests__/ScaleReference.test.js index cc0fa24..e7ee283 100644 --- a/__tests__/ScaleReference.test.js +++ b/__tests__/ScaleReference.test.js @@ -185,4 +185,28 @@ describe('the reference table', () => { expect(key).toBe(key.toUpperCase()); } }); + + it('uses the Royal Mint sizes, and calls only round objects exact', () => { + // The £1 was listed at 23.03 mm; the Royal Mint gives 23.43 mm. The £1 + // (12 sides) and 20p (7 sides) are polygons, which an ellipse fit does + // not match exactly. + const refs = ScaleReference.REFERENCES; + expect(refs.GBP_1).toMatchObject({diameterMm: 23.43, exact: false}); + expect(refs.GBP_20P).toMatchObject({diameterMm: 21.4, exact: false}); + for (const key of ['STICKER_10MM', 'GBP_2', 'EUR_1', 'EUR_2', 'USD_QUARTER', 'USD_PENNY']) { + expect(refs[key].exact).toBe(true); + } + }); + + 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'); + expect(Object.isFrozen(DEFAULT_REFERENCES.STICKER_10MM)).toBe(true); + expect(() => { + 'use strict'; + ScaleReference.REFERENCES.STICKER_10MM.diameterMm = 99; + }).toThrow(TypeError); + expect(ScaleReference.REFERENCES.STICKER_10MM.diameterMm).toBe(10); + }); }); diff --git a/package.json b/package.json index f8ae266..2d55a9c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@molecare/scale-reference", "version": "0.1.0", - "description": "Pixel↔mm conversion from known-diameter reference objects.", + "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", "repository": { @@ -17,9 +17,9 @@ "scale", "calibration", "measurement", - "healthcare" + "photo", + "coin" ], - "peerDependencies": {}, "devDependencies": { "@babel/core": "^7.24.0", "@babel/preset-env": "^7.24.0", @@ -33,8 +33,7 @@ } }, "scripts": { - "test": "jest", - "lint": "echo \"no lint configured yet\"" + "test": "jest" }, "files": [ "src", @@ -43,6 +42,6 @@ "SECURITY.md" ], "publishConfig": { - "access": "restricted" + "access": "public" } } diff --git a/src/ScaleReference.js b/src/ScaleReference.js index e55767d..02b96ba 100644 --- a/src/ScaleReference.js +++ b/src/ScaleReference.js @@ -62,6 +62,10 @@ export default class ScaleReference { } /** + * An estimate, not a measurement. The 0.1 mm rounding is for display; the + * real error is larger (tilt, reference not level with the subject, lens + * distortion, detection), see "Limits" in the README. + * * @param {number} pixels * @param {Object} scale - a result from fromEllipse * @returns {number|null} millimetres, rounded to 0.1mm @@ -105,7 +109,7 @@ export default class ScaleReference { 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.'; case 'reference_too_tilted': - return 'The reference object is at too much of an angle. Hold the camera square to the skin so the object looks round rather than oval.'; + return 'The reference object is at too much of an angle. Hold the camera square to the subject so the object looks round rather than oval.'; default: return 'This photo cannot be measured. Place a reference object flat beside the subject and take it square on.'; } diff --git a/src/config.js b/src/config.js index aa2726b..063d48e 100644 --- a/src/config.js +++ b/src/config.js @@ -1,21 +1,27 @@ /** * Runtime configuration for @molecare/scale-reference. * REFERENCES catalog is injectable so apps can add/remove known objects. + * + * `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: { - label: '10mm calibration sticker', - diameterMm: 10, - exact: true, - }, - GBP_1: {label: 'UK £1', diameterMm: 23.03, exact: true}, - GBP_2: {label: 'UK £2', diameterMm: 28.4, exact: true}, - GBP_20P: {label: 'UK 20p', diameterMm: 21.4, exact: true}, - EUR_1: {label: '€1', diameterMm: 23.25, exact: true}, - EUR_2: {label: '€2', diameterMm: 25.75, exact: true}, - USD_QUARTER: {label: 'US quarter', diameterMm: 24.26, exact: true}, - USD_PENNY: {label: 'US penny', diameterMm: 19.05, exact: true}, + 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), }); const DEFAULTS = Object.freeze({