From 1e3931e29c91522e4b766068db1d9fa5f00794b8 Mon Sep 17 00:00:00 2001 From: whcrg <46355292+whcrg@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:30:16 +0200 Subject: [PATCH 1/4] Finnish photo specs per Police guide; clearer head guides Check the Finnish passport, ID card and visa specs against the Finnish Police photo guide (Poliisin passikuvaohje 2020) and fix them: - Head height 32-36 mm (445-500 px online), not "at least 35 mm" (487 px), which was above the official maximum range. - Add the guide's other bounds: 4-6 mm above the crown, 7-9 mm below the chin, face centre line within 1.5 mm of the photo centre. - Online photos are 500x653 px = 36x47 mm, so use 352.8 dpi instead of 600 (which made them print at 21x28 mm). To express these, specs can now set maxHeadOffset, min/maxChinOffset and maxCenterOffset. Automatic framing keeps the crown and chin inside those bands, and the head guide draws them as min/max marker lines. The guide also gets a vertical centre line (with the allowed drift either side when a spec sets one), and every guide stroke is drawn as alternating black and white dashes so it stays visible on any photo. Co-Authored-By: Claude Opus 5.5 --- packages/common/src/config/spec.json | 59 +++++++--- packages/common/src/config/spec.test.ts | 14 +++ packages/common/src/types/index.ts | 8 ++ packages/common/src/utils/headGuide.ts | 122 +++++++++++++++----- packages/common/src/utils/placement.test.ts | 20 ++++ packages/common/src/utils/placement.ts | 30 ++++- packages/mcp/dist/data/spec.json | 59 +++++++--- packages/mcp/dist/index.js | 13 ++- 8 files changed, 256 insertions(+), 69 deletions(-) diff --git a/packages/common/src/config/spec.json b/packages/common/src/config/spec.json index 59db674..84f46cb 100644 --- a/packages/common/src/config/spec.json +++ b/packages/common/src/config/spec.json @@ -3046,11 +3046,16 @@ "unit": "mm" }, "rules": { - "minHeadHeight": 0.7446808510638298, - "minHeadOffset": 0.0851063829787234 + "minHeadHeight": 0.6808510638297872, + "maxHeadHeight": 0.7659574468085106, + "minHeadOffset": 0.0851063829787234, + "maxHeadOffset": 0.1276595744680851, + "minChinOffset": 0.14893617021276595, + "maxChinOffset": 0.19148936170212766, + "maxCenterOffset": 0.031914893617021274 }, "background": "#d3d3d3", - "comment": "The size (width x height) of Finland Passport photo is 36x47 (in mm) or 3.6x4.7 (in cm) or 1.42x1.85 (in inches) or 850x1110 (in pixels at 600DPI)\nCare should be taken to position the face within the photo as per the following requirements for Finland Passport photo. The minimum head height should be 35mm. Top of the head should be at a minimum of 4mm from the top. \nPhoto for Finland Passport should be taken in a Light Grey background as shown in this example. \nYou should print Finland Passport photo on Glossy/Matte type of photo paper" + "comment": "The size (width x height) of Finland Passport photo is 36x47 mm.\nHead height from crown to chin (excluding hair) must be 32-36 mm. Space above the crown must be 4-6 mm and below the chin 7-9 mm. The centre line of the face may be at most 1.5 mm from the centre line of the photo.\nThe background should be plain, light and neutral; light grey is best.\nSource: Poliisin passikuvaohje (Finnish Police photo guide, 2020), https://poliisi.fi/documents/25235045/31329600/Poliisin-passikuvaohje-2020-FI.pdf" }, { "name": "finland_visa", @@ -3066,11 +3071,16 @@ "unit": "mm" }, "rules": { - "minHeadHeight": 0.7446808510638298, - "minHeadOffset": 0.0851063829787234 + "minHeadHeight": 0.6808510638297872, + "maxHeadHeight": 0.7659574468085106, + "minHeadOffset": 0.0851063829787234, + "maxHeadOffset": 0.1276595744680851, + "minChinOffset": 0.14893617021276595, + "maxChinOffset": 0.19148936170212766, + "maxCenterOffset": 0.031914893617021274 }, "background": "#d3d3d3", - "comment": "The size (width x height) of Finland Visa photo is 36x47 (in mm) or 3.6x4.7 (in cm) or 1.42x1.85 (in inches) or 850x1110 (in pixels at 600DPI)\nCare should be taken to position the face within the photo as per the following requirements for Finland Visa photo. The minimum head height should be 35mm. Top of the head should be at a minimum of 4mm from the top. \nPhoto for Finland Visa should be taken in a Light Grey background as shown in this example. \nYou should print Finland Visa photo on Glossy/Matte type of photo paper" + "comment": "The size (width x height) of Finland Visa photo is 36x47 mm.\nHead height from crown to chin (excluding hair) must be 32-36 mm. Space above the crown must be 4-6 mm and below the chin 7-9 mm. The centre line of the face may be at most 1.5 mm from the centre line of the photo.\nThe background should be plain, light and neutral; light grey is best.\nSource: Poliisin passikuvaohje (Finnish Police photo guide, 2020), https://poliisi.fi/documents/25235045/31329600/Poliisin-passikuvaohje-2020-FI.pdf" }, { "name": "finland_passport_online", @@ -3080,18 +3090,23 @@ "zone": "FI", "flag": "🇫🇮", "dimension": { - "dpi": 600, + "dpi": 352.8, "width": 500, "height": 653, "unit": "mm" }, "rules": { "size": 250, - "minHeadHeight": 0.7457886676875957, - "minHeadOffset": 0.08575803981623277 + "minHeadHeight": 0.6814701378254211, + "maxHeadHeight": 0.7656967840735069, + "minHeadOffset": 0.08575803981623277, + "maxHeadOffset": 0.12863705972434916, + "minChinOffset": 0.14701378254211334, + "maxChinOffset": 0.18989280245022971, + "maxCenterOffset": 0.03215926493108729 }, "background": "#d3d3d3", - "comment": "The size (width x height) of Finland Passport online photo is 21.17x27.64 (in mm) or 2.12x2.76 (in cm) or 0.83x1.09 (in inches) or 500x653 (in pixels at 600DPI)\nCare should be taken to position the face within the photo as per the following requirements for Finland Passport online photo. The minimum head height should be 487pixel. Top of the head should be at a minimum of 56pixel from the top. \nPhoto for Finland Passport online should be taken in a Light Grey background as shown in this example. \nThe Maximum file size for Finland Passport online should be 250 KB" + "comment": "The Finland Passport online photo must be exactly 500x653 pixels (36x47 mm), JPEG, at most 250 KB.\nHead height from crown to chin (excluding hair) must be 445-500 px. Space above the crown must be 56-84 px and below the chin 96-124 px. The centre line of the face may be at most 21 px from the centre line of the photo.\nThe background should be plain, light and neutral; light grey is best.\nSource: Poliisin passikuvaohje (Finnish Police photo guide, 2020), https://poliisi.fi/documents/25235045/31329600/Poliisin-passikuvaohje-2020-FI.pdf" }, { "name": "finland_id_card_online", @@ -3101,18 +3116,23 @@ "zone": "FI", "flag": "🇫🇮", "dimension": { - "dpi": 600, + "dpi": 352.8, "width": 500, "height": 653, "unit": "mm" }, "rules": { "size": 250, - "minHeadHeight": 0.7457886676875957, - "minHeadOffset": 0.08575803981623277 + "minHeadHeight": 0.6814701378254211, + "maxHeadHeight": 0.7656967840735069, + "minHeadOffset": 0.08575803981623277, + "maxHeadOffset": 0.12863705972434916, + "minChinOffset": 0.14701378254211334, + "maxChinOffset": 0.18989280245022971, + "maxCenterOffset": 0.03215926493108729 }, "background": "#d3d3d3", - "comment": "The size (width x height) of Finland ID card online photo is 21.17x27.64 (in mm) or 2.12x2.76 (in cm) or 0.83x1.09 (in inches) or 500x653 (in pixels at 600DPI)\nCare should be taken to position the face within the photo as per the following requirements for Finland ID card online photo. The minimum head height should be 487pixel. Top of the head should be at a minimum of 56pixel from the top. \nPhoto for Finland ID card online should be taken in a Light Grey background as shown in this example. \nThe Maximum file size for Finland ID card online should be 250 KB" + "comment": "The Finland ID card online photo must be exactly 500x653 pixels (36x47 mm), JPEG, at most 250 KB.\nHead height from crown to chin (excluding hair) must be 445-500 px. Space above the crown must be 56-84 px and below the chin 96-124 px. The centre line of the face may be at most 21 px from the centre line of the photo.\nThe background should be plain, light and neutral; light grey is best.\nSource: Poliisin passikuvaohje (Finnish Police photo guide, 2020), https://poliisi.fi/documents/25235045/31329600/Poliisin-passikuvaohje-2020-FI.pdf" }, { "name": "finland_id_card", @@ -3128,11 +3148,16 @@ "unit": "mm" }, "rules": { - "minHeadHeight": 0.7446808510638298, - "minHeadOffset": 0.0851063829787234 + "minHeadHeight": 0.6808510638297872, + "maxHeadHeight": 0.7659574468085106, + "minHeadOffset": 0.0851063829787234, + "maxHeadOffset": 0.1276595744680851, + "minChinOffset": 0.14893617021276595, + "maxChinOffset": 0.19148936170212766, + "maxCenterOffset": 0.031914893617021274 }, "background": "#d3d3d3", - "comment": "The size (width x height) of Finland ID card photo is 36x47 (in mm) or 3.6x4.7 (in cm) or 1.42x1.85 (in inches) or 850x1110 (in pixels at 600DPI)\nCare should be taken to position the face within the photo as per the following requirements for Finland ID card photo. The minimum head height should be 35mm. Top of the head should be at a minimum of 4mm from the top. \nPhoto for Finland ID card should be taken in a Light Grey background as shown in this example. \nYou should print Finland ID card photo on Glossy/Matte type of photo paper" + "comment": "The size (width x height) of Finland ID card photo is 36x47 mm.\nHead height from crown to chin (excluding hair) must be 32-36 mm. Space above the crown must be 4-6 mm and below the chin 7-9 mm. The centre line of the face may be at most 1.5 mm from the centre line of the photo.\nThe background should be plain, light and neutral; light grey is best.\nSource: Poliisin passikuvaohje (Finnish Police photo guide, 2020), https://poliisi.fi/documents/25235045/31329600/Poliisin-passikuvaohje-2020-FI.pdf" }, { "name": "france_passport", diff --git a/packages/common/src/config/spec.test.ts b/packages/common/src/config/spec.test.ts index eb02dc8..dfc94e6 100644 --- a/packages/common/src/config/spec.test.ts +++ b/packages/common/src/config/spec.test.ts @@ -34,12 +34,26 @@ describe('spec.json', () => { 'minHeadHeight', 'maxHeadHeight', 'minHeadOffset', + 'maxHeadOffset', + 'minChinOffset', + 'maxChinOffset', + 'maxCenterOffset', ] as const) { const value = rules[key] if (value !== undefined && !(value > 0 && value <= 1)) { bad.push(`${spec.name}.${key}=${value}`) } } + for (const [min, max] of [ + ['minHeadOffset', 'maxHeadOffset'], + ['minChinOffset', 'maxChinOffset'], + ] as const) { + const lo = rules[min] + const hi = rules[max] + if (lo !== undefined && hi !== undefined && lo > hi) { + bad.push(`${spec.name}: ${min} > ${max}`) + } + } const { minHeadHeight, maxHeadHeight, minHeadOffset = 0 } = rules if ( minHeadHeight !== undefined && diff --git a/packages/common/src/types/index.ts b/packages/common/src/types/index.ts index d06e68d..1e2b6da 100644 --- a/packages/common/src/types/index.ts +++ b/packages/common/src/types/index.ts @@ -29,6 +29,14 @@ export interface IDPhotoSpec { minHeadHeight?: number /** Clear space required above the crown, measured from the top edge. */ minHeadOffset?: number + /** Largest clear space allowed above the crown. */ + maxHeadOffset?: number + /** Clear space required below the chin, measured from the bottom edge. */ + minChinOffset?: number + /** Largest clear space allowed below the chin. */ + maxChinOffset?: number + /** How far the face's centre line may stray from the photo's centre line. */ + maxCenterOffset?: number /** Largest chin-to-crown head height. */ maxHeadHeight?: number /** Eye line distance from the BOTTOM edge, lower bound (min ≤ max). */ diff --git a/packages/common/src/utils/headGuide.ts b/packages/common/src/utils/headGuide.ts index 5f2472b..5a3a93f 100644 --- a/packages/common/src/utils/headGuide.ts +++ b/packages/common/src/utils/headGuide.ts @@ -9,11 +9,30 @@ function round(value: number) { return Math.round(value * 10) / 10 } +function range(min: number, max: number) { + return round(min) === round(max) + ? `${round(min)} mm` + : `${round(min)}-${round(max)} mm` +} + +/** + * Guide strokes are drawn twice, a solid black line under white dashes, so + * they read as alternating black and white segments on any photo. + */ +const DASH = '4,4' +const stroke = (shape: (attrs: string) => string, width = 1) => + shape(`fill="none" stroke="black" stroke-width="${width}"`) + + shape( + `fill="none" stroke="white" stroke-width="${width}" stroke-dasharray="${DASH}"` + ) + /** * An SVG overlay showing where the head must sit, from the same placement - * model automatic framing uses: the target crown line, the band the chin must - * land in, the eye band when the spec has one, a shaded clear-space strip at - * the top, and head outlines at the smallest and largest permitted heights. + * model automatic framing uses: the crown line (or the band the crown must + * land in), the band the chin must land in, the eye band when the spec has + * one, a shaded clear-space strip at the top, head outlines at the smallest + * and largest permitted heights, and the vertical centre line (with the + * allowed drift either side when the spec sets one). * Returned as a data URL for use as a background image. */ export function createHeadGuideSVG( @@ -26,7 +45,17 @@ export function createHeadGuideSVG( return '' } const { width, height } = size - const { minHead, maxHead, minEye, maxEye, crownOffset } = placement + const { + minHead, + maxHead, + minEye, + maxEye, + crownOffset, + maxCrownOffset, + minChin, + maxChin, + } = placement + const { photoHeightMm } = metrics const smallest = minHead || placement.headRatio const largest = maxHead || smallest // With an eye band, heads of every size are anchored on its middle; @@ -40,12 +69,25 @@ export function createHeadGuideSVG( const ellipse = (head: number) => { const top = y(crownFor(head)) const h = head * height - return `` + return stroke( + (attrs) => + `` + ) } - const hline = (at: number, dash: string, opacity = 0.7) => - `` + const hline = (at: number) => + stroke( + (attrs) => + `` + ) + const vline = (at: number, opacity = 1) => + stroke( + (attrs) => + `` + ) + const band = (top: number, bottom: number, fill: string) => + bottom > top + ? `` + : '' const font = Math.max(9, Math.min(13, height * 0.028)) const label = (at: number, text: string, anchor: 'start' | 'end') => @@ -53,11 +95,32 @@ export function createHeadGuideSVG( const hasRange = largest > smallest const headLabel = hasRange - ? `${round(metrics.minHeadMm)}-${round(metrics.maxHeadMm)} mm` + ? range(metrics.minHeadMm, metrics.maxHeadMm) : `min ${round(metrics.minHeadMm || metrics.maxHeadMm)} mm` - const crownY = y(placement.crown) - const chinMinY = y(crownFor(smallest) + smallest) - const chinMaxY = y(crownFor(largest) + largest) + + // Crown: the band between the smallest and largest clear space when the + // spec bounds both, otherwise the target line. + const hasCrownBand = crownOffset > 0 && maxCrownOffset > crownOffset + const crownTopY = y(hasCrownBand ? crownOffset : placement.crown) + const crownBottomY = y(hasCrownBand ? maxCrownOffset : placement.crown) + const crownLabel = hasCrownBand + ? `crown ${range(crownOffset * photoHeightMm, maxCrownOffset * photoHeightMm)} from top` + : 'crown' + + // Chin: the spec's own clear-space band below the chin when it has one, + // otherwise where the smallest and largest heads end. + const chinTop = maxChin || minChin + const chinBottom = minChin || maxChin + const chinTopY = chinTop + ? y(1 - chinTop) + : y(crownFor(smallest) + smallest) + const chinBottomY = chinBottom + ? y(1 - chinBottom) + : y(crownFor(largest) + largest) + const chinLabel = + minChin > 0 && maxChin > minChin + ? `chin ${range(minChin * photoHeightMm, maxChin * photoHeightMm)} from bottom · head ${headLabel}` + : `chin · head ${headLabel}` let eyeBand = '' if (minEye > 0 || maxEye > 0) { @@ -65,28 +128,31 @@ export function createHeadGuideSVG( const bottom = y(1 - (minEye || maxEye)) eyeBand = (bottom > top - ? `` - : hline(top, '2,3', 0.6)) + label(bottom - 4, 'eyes', 'end') + ? band(top, bottom, 'rgba(110,170,255,0.18)') + : hline(top)) + label(bottom - 4, 'eyes', 'end') } + const centerOffset = spec.rules?.maxCenterOffset ?? 0 + const centerDrift = centerOffset > 0 ? y(centerOffset) : 0 + const svg = `` + - (crownOffset > 0 - ? `` - : '') + + (crownOffset > 0 ? band(0, y(crownOffset), 'rgba(0,0,0,0.22)') : '') + + (hasCrownBand ? band(crownTopY, crownBottomY, 'rgba(80,220,140,0.2)') : '') + eyeBand + - (hasRange - ? `` - : '') + + band(chinTopY, chinBottomY, 'rgba(80,220,140,0.2)') + ellipse(smallest) + (hasRange ? ellipse(largest) : '') + - hline(crownY, '') + - hline(chinMinY, '4,3') + - (hasRange ? hline(chinMaxY, '4,3') : '') + - label(Math.max(font, crownY - 4), 'crown', 'start') + - label(chinMinY - 4, `chin · head ${headLabel}`, 'start') + + vline(cx) + + (centerDrift > 0 + ? vline(cx - centerDrift, 0.6) + vline(cx + centerDrift, 0.6) + : '') + + hline(crownTopY) + + (crownBottomY > crownTopY ? hline(crownBottomY) : '') + + hline(chinTopY) + + (chinBottomY > chinTopY ? hline(chinBottomY) : '') + + label(Math.max(font, crownTopY - 4), crownLabel, 'start') + + label(chinTopY - 4, chinLabel, 'start') + `` return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` diff --git a/packages/common/src/utils/placement.test.ts b/packages/common/src/utils/placement.test.ts index f6741f3..3ea44f0 100644 --- a/packages/common/src/utils/placement.test.ts +++ b/packages/common/src/utils/placement.test.ts @@ -35,6 +35,26 @@ describe('getHeadPlacement', () => { expect(p.chin).toBeLessThanOrEqual(1) }) + it.each(['finland_passport', 'finland_passport_online'])( + 'places the %s head inside the Finnish Police bands', + (name) => { + // Poliisin passikuvaohje 2020: head 32-36 mm, 4-6 mm above the crown, + // 7-9 mm below the chin, on a 47 mm tall photo (653 px online). + const spec = byName(name) + const H = name.endsWith('online') ? 653 : 47 + const mm = H / 47 + const p = getHeadPlacement(spec)! + const rules = getPlacementRules(spec) + expect(solveCrown(rules, p.headRatio).fits).toBe(true) + expect(p.headRatio * H).toBeGreaterThanOrEqual(32 * mm - 0.5) + expect(p.headRatio * H).toBeLessThanOrEqual(36 * mm + 0.5) + expect(p.crown * H).toBeGreaterThanOrEqual(4 * mm - 0.5) + expect(p.crown * H).toBeLessThanOrEqual(6 * mm + 0.5) + expect((1 - p.chin) * H).toBeGreaterThanOrEqual(7 * mm - 0.5) + expect((1 - p.chin) * H).toBeLessThanOrEqual(9 * mm + 0.5) + } + ) + it.each(all.filter((s) => getHeadPlacement(s)).map((s) => [s.name, s] as const))( '%s: meets every rule that can be met', (_name, spec) => { diff --git a/packages/common/src/utils/placement.ts b/packages/common/src/utils/placement.ts index a44df12..b26c54d 100644 --- a/packages/common/src/utils/placement.ts +++ b/packages/common/src/utils/placement.ts @@ -26,6 +26,11 @@ export interface PlacementRules { maxHead: number /** Minimum clear space above the crown. */ crownOffset: number + /** Maximum clear space above the crown. */ + maxCrownOffset: number + /** Clear space below the chin, above the bottom edge. */ + minChin: number + maxChin: number /** Eye line height above the bottom edge. */ minEye: number maxEye: number @@ -38,6 +43,9 @@ export function getPlacementRules( minHeadHeight = 0, maxHeadHeight = 0, minHeadOffset = 0, + maxHeadOffset = 0, + minChinOffset = 0, + maxChinOffset = 0, minEyeHeight = 0, maxEyeHeight = 0, } = spec.rules ?? {} @@ -46,6 +54,9 @@ export function getPlacementRules( minHead: positive(minHeadHeight), maxHead: positive(maxHeadHeight), crownOffset: positive(minHeadOffset), + maxCrownOffset: positive(maxHeadOffset), + minChin: positive(minChinOffset), + maxChin: positive(maxChinOffset), minEye: positive(minEyeHeight), maxEye: positive(maxEyeHeight), } @@ -70,27 +81,34 @@ export function getHeadRatioRange(rules: PlacementRules) { /** * The crown position for a head of height `headRatio` whose eye line sits * `eyeInHead` of the way down it. The eye line goes to the middle of the - * spec's eye band when it has both bounds; otherwise the crown goes on the - * required clear-space line, or a little above the middle of the free space. - * The result is then kept inside every rule that can be met: clear space - * above the crown, the eye band and the chin inside the photo. `fits` is - * false when they cannot all be met at this head height. + * spec's eye band when it has both bounds; otherwise the crown goes in the + * middle of its clear-space band, on the required clear-space line, or a + * little above the middle of the free space. The result is then kept inside + * every rule that can be met: clear space above the crown and below the chin, + * the eye band and the chin inside the photo. `fits` is false when they + * cannot all be met at this head height. */ export function solveCrown( rules: PlacementRules, headRatio: number, eyeInHead = EYE_IN_HEAD ) { - const { crownOffset, minEye, maxEye } = rules + const { crownOffset, maxCrownOffset, minChin, maxChin, minEye, maxEye } = + rules const eyeDrop = eyeInHead * headRatio let lo = crownOffset let hi = 1 - headRatio + if (maxCrownOffset > 0) hi = Math.min(hi, maxCrownOffset) + if (minChin > 0) hi = Math.min(hi, 1 - minChin - headRatio) + if (maxChin > 0) lo = Math.max(lo, 1 - maxChin - headRatio) if (minEye > 0) hi = Math.min(hi, 1 - minEye - eyeDrop) if (maxEye > 0) lo = Math.max(lo, 1 - maxEye - eyeDrop) let crown: number if (minEye > 0 && maxEye > 0) { crown = 1 - (minEye + maxEye) / 2 - eyeDrop + } else if (crownOffset > 0 && maxCrownOffset > 0) { + crown = (crownOffset + maxCrownOffset) / 2 } else if (crownOffset > 0) { crown = crownOffset } else { diff --git a/packages/mcp/dist/data/spec.json b/packages/mcp/dist/data/spec.json index 59db674..84f46cb 100644 --- a/packages/mcp/dist/data/spec.json +++ b/packages/mcp/dist/data/spec.json @@ -3046,11 +3046,16 @@ "unit": "mm" }, "rules": { - "minHeadHeight": 0.7446808510638298, - "minHeadOffset": 0.0851063829787234 + "minHeadHeight": 0.6808510638297872, + "maxHeadHeight": 0.7659574468085106, + "minHeadOffset": 0.0851063829787234, + "maxHeadOffset": 0.1276595744680851, + "minChinOffset": 0.14893617021276595, + "maxChinOffset": 0.19148936170212766, + "maxCenterOffset": 0.031914893617021274 }, "background": "#d3d3d3", - "comment": "The size (width x height) of Finland Passport photo is 36x47 (in mm) or 3.6x4.7 (in cm) or 1.42x1.85 (in inches) or 850x1110 (in pixels at 600DPI)\nCare should be taken to position the face within the photo as per the following requirements for Finland Passport photo. The minimum head height should be 35mm. Top of the head should be at a minimum of 4mm from the top. \nPhoto for Finland Passport should be taken in a Light Grey background as shown in this example. \nYou should print Finland Passport photo on Glossy/Matte type of photo paper" + "comment": "The size (width x height) of Finland Passport photo is 36x47 mm.\nHead height from crown to chin (excluding hair) must be 32-36 mm. Space above the crown must be 4-6 mm and below the chin 7-9 mm. The centre line of the face may be at most 1.5 mm from the centre line of the photo.\nThe background should be plain, light and neutral; light grey is best.\nSource: Poliisin passikuvaohje (Finnish Police photo guide, 2020), https://poliisi.fi/documents/25235045/31329600/Poliisin-passikuvaohje-2020-FI.pdf" }, { "name": "finland_visa", @@ -3066,11 +3071,16 @@ "unit": "mm" }, "rules": { - "minHeadHeight": 0.7446808510638298, - "minHeadOffset": 0.0851063829787234 + "minHeadHeight": 0.6808510638297872, + "maxHeadHeight": 0.7659574468085106, + "minHeadOffset": 0.0851063829787234, + "maxHeadOffset": 0.1276595744680851, + "minChinOffset": 0.14893617021276595, + "maxChinOffset": 0.19148936170212766, + "maxCenterOffset": 0.031914893617021274 }, "background": "#d3d3d3", - "comment": "The size (width x height) of Finland Visa photo is 36x47 (in mm) or 3.6x4.7 (in cm) or 1.42x1.85 (in inches) or 850x1110 (in pixels at 600DPI)\nCare should be taken to position the face within the photo as per the following requirements for Finland Visa photo. The minimum head height should be 35mm. Top of the head should be at a minimum of 4mm from the top. \nPhoto for Finland Visa should be taken in a Light Grey background as shown in this example. \nYou should print Finland Visa photo on Glossy/Matte type of photo paper" + "comment": "The size (width x height) of Finland Visa photo is 36x47 mm.\nHead height from crown to chin (excluding hair) must be 32-36 mm. Space above the crown must be 4-6 mm and below the chin 7-9 mm. The centre line of the face may be at most 1.5 mm from the centre line of the photo.\nThe background should be plain, light and neutral; light grey is best.\nSource: Poliisin passikuvaohje (Finnish Police photo guide, 2020), https://poliisi.fi/documents/25235045/31329600/Poliisin-passikuvaohje-2020-FI.pdf" }, { "name": "finland_passport_online", @@ -3080,18 +3090,23 @@ "zone": "FI", "flag": "🇫🇮", "dimension": { - "dpi": 600, + "dpi": 352.8, "width": 500, "height": 653, "unit": "mm" }, "rules": { "size": 250, - "minHeadHeight": 0.7457886676875957, - "minHeadOffset": 0.08575803981623277 + "minHeadHeight": 0.6814701378254211, + "maxHeadHeight": 0.7656967840735069, + "minHeadOffset": 0.08575803981623277, + "maxHeadOffset": 0.12863705972434916, + "minChinOffset": 0.14701378254211334, + "maxChinOffset": 0.18989280245022971, + "maxCenterOffset": 0.03215926493108729 }, "background": "#d3d3d3", - "comment": "The size (width x height) of Finland Passport online photo is 21.17x27.64 (in mm) or 2.12x2.76 (in cm) or 0.83x1.09 (in inches) or 500x653 (in pixels at 600DPI)\nCare should be taken to position the face within the photo as per the following requirements for Finland Passport online photo. The minimum head height should be 487pixel. Top of the head should be at a minimum of 56pixel from the top. \nPhoto for Finland Passport online should be taken in a Light Grey background as shown in this example. \nThe Maximum file size for Finland Passport online should be 250 KB" + "comment": "The Finland Passport online photo must be exactly 500x653 pixels (36x47 mm), JPEG, at most 250 KB.\nHead height from crown to chin (excluding hair) must be 445-500 px. Space above the crown must be 56-84 px and below the chin 96-124 px. The centre line of the face may be at most 21 px from the centre line of the photo.\nThe background should be plain, light and neutral; light grey is best.\nSource: Poliisin passikuvaohje (Finnish Police photo guide, 2020), https://poliisi.fi/documents/25235045/31329600/Poliisin-passikuvaohje-2020-FI.pdf" }, { "name": "finland_id_card_online", @@ -3101,18 +3116,23 @@ "zone": "FI", "flag": "🇫🇮", "dimension": { - "dpi": 600, + "dpi": 352.8, "width": 500, "height": 653, "unit": "mm" }, "rules": { "size": 250, - "minHeadHeight": 0.7457886676875957, - "minHeadOffset": 0.08575803981623277 + "minHeadHeight": 0.6814701378254211, + "maxHeadHeight": 0.7656967840735069, + "minHeadOffset": 0.08575803981623277, + "maxHeadOffset": 0.12863705972434916, + "minChinOffset": 0.14701378254211334, + "maxChinOffset": 0.18989280245022971, + "maxCenterOffset": 0.03215926493108729 }, "background": "#d3d3d3", - "comment": "The size (width x height) of Finland ID card online photo is 21.17x27.64 (in mm) or 2.12x2.76 (in cm) or 0.83x1.09 (in inches) or 500x653 (in pixels at 600DPI)\nCare should be taken to position the face within the photo as per the following requirements for Finland ID card online photo. The minimum head height should be 487pixel. Top of the head should be at a minimum of 56pixel from the top. \nPhoto for Finland ID card online should be taken in a Light Grey background as shown in this example. \nThe Maximum file size for Finland ID card online should be 250 KB" + "comment": "The Finland ID card online photo must be exactly 500x653 pixels (36x47 mm), JPEG, at most 250 KB.\nHead height from crown to chin (excluding hair) must be 445-500 px. Space above the crown must be 56-84 px and below the chin 96-124 px. The centre line of the face may be at most 21 px from the centre line of the photo.\nThe background should be plain, light and neutral; light grey is best.\nSource: Poliisin passikuvaohje (Finnish Police photo guide, 2020), https://poliisi.fi/documents/25235045/31329600/Poliisin-passikuvaohje-2020-FI.pdf" }, { "name": "finland_id_card", @@ -3128,11 +3148,16 @@ "unit": "mm" }, "rules": { - "minHeadHeight": 0.7446808510638298, - "minHeadOffset": 0.0851063829787234 + "minHeadHeight": 0.6808510638297872, + "maxHeadHeight": 0.7659574468085106, + "minHeadOffset": 0.0851063829787234, + "maxHeadOffset": 0.1276595744680851, + "minChinOffset": 0.14893617021276595, + "maxChinOffset": 0.19148936170212766, + "maxCenterOffset": 0.031914893617021274 }, "background": "#d3d3d3", - "comment": "The size (width x height) of Finland ID card photo is 36x47 (in mm) or 3.6x4.7 (in cm) or 1.42x1.85 (in inches) or 850x1110 (in pixels at 600DPI)\nCare should be taken to position the face within the photo as per the following requirements for Finland ID card photo. The minimum head height should be 35mm. Top of the head should be at a minimum of 4mm from the top. \nPhoto for Finland ID card should be taken in a Light Grey background as shown in this example. \nYou should print Finland ID card photo on Glossy/Matte type of photo paper" + "comment": "The size (width x height) of Finland ID card photo is 36x47 mm.\nHead height from crown to chin (excluding hair) must be 32-36 mm. Space above the crown must be 4-6 mm and below the chin 7-9 mm. The centre line of the face may be at most 1.5 mm from the centre line of the photo.\nThe background should be plain, light and neutral; light grey is best.\nSource: Poliisin passikuvaohje (Finnish Police photo guide, 2020), https://poliisi.fi/documents/25235045/31329600/Poliisin-passikuvaohje-2020-FI.pdf" }, { "name": "france_passport", diff --git a/packages/mcp/dist/index.js b/packages/mcp/dist/index.js index 016da25..9bfb8fc 100755 --- a/packages/mcp/dist/index.js +++ b/packages/mcp/dist/index.js @@ -393,6 +393,9 @@ function getPlacementRules(spec) { minHeadHeight = 0, maxHeadHeight = 0, minHeadOffset = 0, + maxHeadOffset = 0, + minChinOffset = 0, + maxChinOffset = 0, minEyeHeight = 0, maxEyeHeight = 0 } = spec.rules ?? {}; @@ -401,6 +404,9 @@ function getPlacementRules(spec) { minHead: positive(minHeadHeight), maxHead: positive(maxHeadHeight), crownOffset: positive(minHeadOffset), + maxCrownOffset: positive(maxHeadOffset), + minChin: positive(minChinOffset), + maxChin: positive(maxChinOffset), minEye: positive(minEyeHeight), maxEye: positive(maxEyeHeight) }; @@ -420,15 +426,20 @@ function getHeadRatioRange(rules) { return { target, max }; } function solveCrown(rules, headRatio, eyeInHead = EYE_IN_HEAD) { - const { crownOffset, minEye, maxEye } = rules; + const { crownOffset, maxCrownOffset, minChin, maxChin, minEye, maxEye } = rules; const eyeDrop = eyeInHead * headRatio; let lo = crownOffset; let hi = 1 - headRatio; + if (maxCrownOffset > 0) hi = Math.min(hi, maxCrownOffset); + if (minChin > 0) hi = Math.min(hi, 1 - minChin - headRatio); + if (maxChin > 0) lo = Math.max(lo, 1 - maxChin - headRatio); if (minEye > 0) hi = Math.min(hi, 1 - minEye - eyeDrop); if (maxEye > 0) lo = Math.max(lo, 1 - maxEye - eyeDrop); let crown; if (minEye > 0 && maxEye > 0) { crown = 1 - (minEye + maxEye) / 2 - eyeDrop; + } else if (crownOffset > 0 && maxCrownOffset > 0) { + crown = (crownOffset + maxCrownOffset) / 2; } else if (crownOffset > 0) { crown = crownOffset; } else { From c83e132b20b4aa287d52051dee9a1482d8b60e1e Mon Sep 17 00:00:00 2001 From: whcrg <46355292+whcrg@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:47:58 +0200 Subject: [PATCH 2/4] Fit head outlines to guide bands; background removal strength When a spec has crown or chin bands, the head outlines now span them: the smallest head from the inner edge of the crown band to the inner edge of the chin band, the largest between the outer edges. For the Finnish specs that is exactly 32 and 36 mm. Add a "Background removal" slider (0-100%) to the retouch Background panel. Below 100% the original photo is blended back in under the cutout, so some of the original background shows through and the photo looks less processed. The cropper hands retouching the same crop of the original alongside the cutout. A note says authorities may still treat it as an edited photo. Co-Authored-By: Claude Opus 5.5 --- packages/common/src/locales/af.json | 2 + packages/common/src/locales/ar.json | 2 + packages/common/src/locales/de.json | 2 + packages/common/src/locales/en.json | 2 + packages/common/src/locales/fr.json | 2 + packages/common/src/locales/ja.json | 2 + packages/common/src/locales/ko.json | 2 + packages/common/src/locales/pt.json | 2 + packages/common/src/locales/ru.json | 2 + packages/common/src/locales/th.json | 2 + packages/common/src/locales/tr.json | 2 + packages/common/src/locales/vi.json | 2 + packages/common/src/locales/zh-HK.json | 2 + packages/common/src/locales/zh-TW.json | 2 + packages/common/src/locales/zh.json | 2 + packages/common/src/pages/cropper/index.tsx | 14 ++- .../pages/retouch/BackgroundColorPicker.tsx | 37 ++++++++ packages/common/src/stores/AppStore.ts | 7 +- packages/common/src/stores/RetouchStore.ts | 86 +++++++++++++++++-- packages/common/src/utils/headGuide.ts | 23 +++-- packages/common/src/utils/image.ts | 39 +++++++++ 21 files changed, 216 insertions(+), 20 deletions(-) diff --git a/packages/common/src/locales/af.json b/packages/common/src/locales/af.json index 2139928..7ecb000 100644 --- a/packages/common/src/locales/af.json +++ b/packages/common/src/locales/af.json @@ -29,6 +29,8 @@ "printSheet": "4x6-duim-vel", "printSheetSaved": "4x6-vel: {count} x {title} ({size}), {columns}x{rows} teen {dpi} DPI.", "guides": "Riglyne", + "backgroundRemoval": "Agtergrondverwydering", + "backgroundRemovalHint": "Onder 100% skyn van die oorspronklike agtergrond deur, wat minder verwerk lyk. Owerhede kan dit steeds as 'n geredigeerde foto beskou.", "filter": "Filter", "filterOriginal": "Oorspronklik", "filterGrayscale": "S&W", diff --git a/packages/common/src/locales/ar.json b/packages/common/src/locales/ar.json index 8b0dfda..719c1c1 100644 --- a/packages/common/src/locales/ar.json +++ b/packages/common/src/locales/ar.json @@ -28,6 +28,8 @@ "printSheet": "ورقة 4x6 بوصة", "printSheetSaved": "ورقة 4x6: {count} × {title} ({size})، {columns}x{rows} بدقة {dpi} DPI.", "guides": "خطوط إرشادية", + "backgroundRemoval": "إزالة الخلفية", + "backgroundRemovalHint": "أقل من 100% يظهر جزء من الخلفية الأصلية، فتبدو الصورة أقل معالجة. قد تعتبرها الجهات الرسمية صورة معدّلة مع ذلك.", "filter": "فلتر", "filterOriginal": "الأصلي", "filterGrayscale": "أبيض وأسود", diff --git a/packages/common/src/locales/de.json b/packages/common/src/locales/de.json index 93c240c..32fcfc2 100644 --- a/packages/common/src/locales/de.json +++ b/packages/common/src/locales/de.json @@ -28,6 +28,8 @@ "printSheet": "4x6-Zoll-Bogen", "printSheetSaved": "4x6-Bogen: {count} x {title} ({size}), {columns}x{rows} bei {dpi} DPI.", "guides": "Hilfslinien", + "backgroundRemoval": "Hintergrundentfernung", + "backgroundRemovalHint": "Unter 100 % scheint etwas vom ursprünglichen Hintergrund durch, das wirkt weniger bearbeitet. Behörden können es trotzdem als bearbeitetes Foto werten.", "filter": "Filter", "filterOriginal": "Original", "filterGrayscale": "S/W", diff --git a/packages/common/src/locales/en.json b/packages/common/src/locales/en.json index c9aa6c7..e29733a 100644 --- a/packages/common/src/locales/en.json +++ b/packages/common/src/locales/en.json @@ -28,6 +28,8 @@ "printSheet": "4x6 inch sheet", "printSheetSaved": "4x6 sheet: {count} x {title} ({size}), {columns}x{rows} at {dpi} DPI.", "guides": "Guides", + "backgroundRemoval": "Background removal", + "backgroundRemovalHint": "Below 100% some of the original background shows through, which looks less processed. Authorities may still count it as an edited photo.", "filter": "Filter", "filterOriginal": "Original", "filterGrayscale": "B&W", diff --git a/packages/common/src/locales/fr.json b/packages/common/src/locales/fr.json index 91f7f4f..d89cf7a 100644 --- a/packages/common/src/locales/fr.json +++ b/packages/common/src/locales/fr.json @@ -28,6 +28,8 @@ "printSheet": "Planche 4x6 pouces", "printSheetSaved": "Planche 4x6 : {count} x {title} ({size}), {columns}x{rows} à {dpi} DPI.", "guides": "Repères", + "backgroundRemoval": "Suppression de l'arrière-plan", + "backgroundRemovalHint": "En dessous de 100 %, une partie de l'arrière-plan d'origine reste visible, ce qui paraît moins retouché. Les autorités peuvent quand même la considérer comme une photo modifiée.", "filter": "Filtre", "filterOriginal": "Original", "filterGrayscale": "N&B", diff --git a/packages/common/src/locales/ja.json b/packages/common/src/locales/ja.json index f95cf10..2977dff 100644 --- a/packages/common/src/locales/ja.json +++ b/packages/common/src/locales/ja.json @@ -28,6 +28,8 @@ "printSheet": "4x6インチ台紙", "printSheetSaved": "4x6台紙:{title}({size})× {count}枚、{columns}x{rows}、{dpi} DPI。", "guides": "ガイド", + "backgroundRemoval": "背景除去", + "backgroundRemovalHint": "100% 未満では元の背景が少し透けて、加工感が減ります。それでも当局は編集済みの写真とみなす場合があります。", "filter": "フィルター", "filterOriginal": "オリジナル", "filterGrayscale": "白黒", diff --git a/packages/common/src/locales/ko.json b/packages/common/src/locales/ko.json index cca387b..80844bb 100644 --- a/packages/common/src/locales/ko.json +++ b/packages/common/src/locales/ko.json @@ -28,6 +28,8 @@ "printSheet": "4x6인치 인화지", "printSheetSaved": "4x6 인화지: {title}({size}) {count}장, {columns}x{rows}, {dpi} DPI.", "guides": "안내선", + "backgroundRemoval": "배경 제거", + "backgroundRemovalHint": "100% 미만이면 원래 배경이 일부 비쳐 덜 가공된 느낌이 납니다. 그래도 기관에서는 편집된 사진으로 볼 수 있습니다.", "filter": "필터", "filterOriginal": "원본", "filterGrayscale": "흑백", diff --git a/packages/common/src/locales/pt.json b/packages/common/src/locales/pt.json index ea0abe4..5513411 100644 --- a/packages/common/src/locales/pt.json +++ b/packages/common/src/locales/pt.json @@ -28,6 +28,8 @@ "printSheet": "Folha de 4x6 polegadas", "printSheetSaved": "Folha 4x6: {count} x {title} ({size}), {columns}x{rows} a {dpi} DPI.", "guides": "Guias", + "backgroundRemoval": "Remoção do fundo", + "backgroundRemovalHint": "Abaixo de 100%, parte do fundo original aparece, o que parece menos processado. As autoridades ainda podem considerá-la uma foto editada.", "filter": "Filtro", "filterOriginal": "Original", "filterGrayscale": "P&B", diff --git a/packages/common/src/locales/ru.json b/packages/common/src/locales/ru.json index 0891e76..e6a883b 100644 --- a/packages/common/src/locales/ru.json +++ b/packages/common/src/locales/ru.json @@ -28,6 +28,8 @@ "printSheet": "Лист 4x6 дюйма", "printSheetSaved": "Лист 4x6: {count} x {title} ({size}), {columns}x{rows}, {dpi} DPI.", "guides": "Направляющие", + "backgroundRemoval": "Удаление фона", + "backgroundRemovalHint": "Ниже 100% часть исходного фона просвечивает, и фото выглядит менее обработанным. Органы всё равно могут счесть его отредактированным.", "filter": "Фильтр", "filterOriginal": "Оригинал", "filterGrayscale": "Ч/Б", diff --git a/packages/common/src/locales/th.json b/packages/common/src/locales/th.json index 921572a..409ac94 100644 --- a/packages/common/src/locales/th.json +++ b/packages/common/src/locales/th.json @@ -28,6 +28,8 @@ "printSheet": "แผ่นขนาด 4x6 นิ้ว", "printSheetSaved": "แผ่น 4x6: {title} ({size}) {count} รูป, {columns}x{rows} ที่ {dpi} DPI", "guides": "เส้นนำ", + "backgroundRemoval": "การลบพื้นหลัง", + "backgroundRemovalHint": "ต่ำกว่า 100% พื้นหลังเดิมจะปรากฏบางส่วน ทำให้ดูผ่านการแต่งน้อยลง แต่หน่วยงานอาจยังถือว่าเป็นภาพที่แก้ไขแล้ว", "filter": "ฟิลเตอร์", "filterOriginal": "ต้นฉบับ", "filterGrayscale": "ขาวดำ", diff --git a/packages/common/src/locales/tr.json b/packages/common/src/locales/tr.json index 90e65b3..2389fbe 100644 --- a/packages/common/src/locales/tr.json +++ b/packages/common/src/locales/tr.json @@ -28,6 +28,8 @@ "printSheet": "4x6 inç sayfa", "printSheetSaved": "4x6 sayfa: {count} x {title} ({size}), {columns}x{rows}, {dpi} DPI.", "guides": "Kılavuzlar", + "backgroundRemoval": "Arka plan kaldırma", + "backgroundRemovalHint": "%100'ün altında orijinal arka planın bir kısmı görünür ve fotoğraf daha az işlenmiş görünür. Yetkililer yine de bunu düzenlenmiş fotoğraf sayabilir.", "filter": "Filtre", "filterOriginal": "Orijinal", "filterGrayscale": "S/B", diff --git a/packages/common/src/locales/vi.json b/packages/common/src/locales/vi.json index a054f66..ab66638 100644 --- a/packages/common/src/locales/vi.json +++ b/packages/common/src/locales/vi.json @@ -28,6 +28,8 @@ "printSheet": "trang 4x6 inch", "printSheetSaved": "Trang 4x6: {count} x {title} ({size}), {columns}x{rows} ở {dpi} DPI.", "guides": "đường gióng", + "backgroundRemoval": "Xóa phông nền", + "backgroundRemovalHint": "Dưới 100%, một phần phông nền gốc sẽ hiện ra, trông ít qua xử lý hơn. Cơ quan chức năng vẫn có thể coi đây là ảnh đã chỉnh sửa.", "filter": "bộ lọc", "filterOriginal": "gốc", "filterGrayscale": "đen trắng", diff --git a/packages/common/src/locales/zh-HK.json b/packages/common/src/locales/zh-HK.json index 57a4b1f..dd57d02 100644 --- a/packages/common/src/locales/zh-HK.json +++ b/packages/common/src/locales/zh-HK.json @@ -28,6 +28,8 @@ "printSheet": "4x6 吋排版相片", "printSheetSaved": "4x6 排版:{count} 張 {title}({size}),{columns}x{rows},{dpi} DPI。", "guides": "參考線", + "backgroundRemoval": "背景移除", + "backgroundRemovalHint": "低於 100% 時會透出部分原有背景,看起來較少處理痕跡。但有關機構仍可能視為經編輯的相片。", "filter": "濾鏡", "filterOriginal": "原圖", "filterGrayscale": "黑白", diff --git a/packages/common/src/locales/zh-TW.json b/packages/common/src/locales/zh-TW.json index 05202a5..b4b47cf 100644 --- a/packages/common/src/locales/zh-TW.json +++ b/packages/common/src/locales/zh-TW.json @@ -28,6 +28,8 @@ "printSheet": "4x6 吋排版相片", "printSheetSaved": "4x6 排版:{count} 張 {title}({size}),{columns}x{rows},{dpi} DPI。", "guides": "參考線", + "backgroundRemoval": "背景移除", + "backgroundRemovalHint": "低於 100% 時會透出部分原始背景,看起來較少處理痕跡。但相關機關仍可能視為經過編輯的照片。", "filter": "濾鏡", "filterOriginal": "原圖", "filterGrayscale": "黑白", diff --git a/packages/common/src/locales/zh.json b/packages/common/src/locales/zh.json index 06a6de3..4542f1a 100644 --- a/packages/common/src/locales/zh.json +++ b/packages/common/src/locales/zh.json @@ -30,6 +30,8 @@ "printSheetTooLarge": "该规格尺寸过大,无法在4x6寸纸张上排版。", "printSheetFailed": "生成排版照片失败。", "guides": "参考线", + "backgroundRemoval": "背景移除", + "backgroundRemovalHint": "低于 100% 时会透出部分原始背景,看起来处理痕迹更少。但相关机构仍可能视为编辑过的照片。", "filter": "滤镜", "filterOriginal": "原图", "filterGrayscale": "黑白", diff --git a/packages/common/src/pages/cropper/index.tsx b/packages/common/src/pages/cropper/index.tsx index 9d8530a..fd4ca13 100644 --- a/packages/common/src/pages/cropper/index.tsx +++ b/packages/common/src/pages/cropper/index.tsx @@ -109,15 +109,23 @@ export default function Cropper() { !result const sourceImage = useOriginal ? image : result - const idPhoto = await cropIDPhoto({ - image: sourceImage, + const cropOptions = { area: croppedArea, rotation, flipHorizontal, flipVertical, resolution: currentSpec.dimension, + } + const idPhoto = await cropIDPhoto({ + ...cropOptions, + image: sourceImage, }) - useAppStore.getState().retouch(idPhoto) + // The same crop of the original lets retouching blend some of the + // original background back in. + const original = useOriginal + ? undefined + : await cropIDPhoto({ ...cropOptions, image }) + useAppStore.getState().retouch(idPhoto, original) } catch (error) { enqueueSnackbar( intl.formatMessage({ diff --git a/packages/common/src/pages/retouch/BackgroundColorPicker.tsx b/packages/common/src/pages/retouch/BackgroundColorPicker.tsx index fc9a6ab..058f260 100644 --- a/packages/common/src/pages/retouch/BackgroundColorPicker.tsx +++ b/packages/common/src/pages/retouch/BackgroundColorPicker.tsx @@ -1,16 +1,25 @@ import chroma from 'chroma-js' import { useCallback, useEffect, useState } from 'react' +import { useIntl } from 'react-intl' import Box from '@mui/material/Box' import Input from '@mui/material/Input' import Tooltip from '@mui/material/Tooltip' +import Typography from '@mui/material/Typography' +import { RangePicker } from '../../components' import { BACKGROUND_COLORS } from '../../config' import { useRetouchStore } from '../../stores' export function BackgroundColorPicker() { + const intl = useIntl() const [background, setBackground] = useRetouchStore((state) => [ state.background, state.setBackground, ]) + const [original, removal, setRemoval] = useRetouchStore((state) => [ + state.original, + state.removal, + state.setRemoval, + ]) const { color } = background const setColor = useCallback( (newColor: string) => { @@ -27,6 +36,34 @@ export function BackgroundColorPicker() { }, [color]) return ( + {original ? ( + + + {intl.formatMessage({ + id: 'backgroundRemoval', + defaultMessage: 'Background removal', + })} + + Math.round(value * 100)} + onChange={setRemoval} + /> + + {intl.formatMessage({ + id: 'backgroundRemovalHint', + defaultMessage: + 'Below 100% some of the original background shows through, which looks less processed. Authorities may still count it as an edited photo.', + })} + + + ) : null} ((set, get) => ({ useCropStore.getState().setImage(image) set({ step: 1 }) }, - retouch(image: string) { - useRetouchStore.getState().setImage(image) + retouch(image: string, original?: string) { + useRetouchStore.getState().setImage(image, original) set({ step: 2 }) }, cancel() { diff --git a/packages/common/src/stores/RetouchStore.ts b/packages/common/src/stores/RetouchStore.ts index 7c2de0a..fabde58 100644 --- a/packages/common/src/stores/RetouchStore.ts +++ b/packages/common/src/stores/RetouchStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand' +import { mixOriginalBackground } from '../utils/image' export type RetouchBackgroundGradientAngle = | 'l' @@ -68,7 +69,17 @@ export interface RetouchAdjustment { } export interface RetouchState { + /** The photo being retouched: the cutout, with `removal` applied. */ image: string + /** The cropped cutout, before any of the original is blended back. */ + cutout: string + /** The same crop of the original photo; empty when there is no cutout. */ + original: string + /** + * How much of the original background is removed, 0 to 1. Below 1 the + * original shows through at `1 - removal` opacity. + */ + removal: number background: RetouchBackground adjustment: RetouchAdjustment /** Show the head-position guides over the preview. */ @@ -76,7 +87,8 @@ export interface RetouchState { } export interface RetouchActions { - setImage(image: string): void + setImage(image: string, original?: string): void + setRemoval(removal: number): void setBackground(background: RetouchBackground): void setAdjustment(adjustment: RetouchAdjustment): void toggleGuides(): void @@ -87,6 +99,9 @@ export interface RetouchStore extends RetouchState, RetouchActions {} export const defaultRetouchState: RetouchState = { image: '', + cutout: '', + original: '', + removal: 1, background: { color: '#ffffff', gradient: { @@ -105,15 +120,71 @@ function revokeBlobURL(url: string) { } } +/** Every blob URL the store owns (the blended image is separate from the cutout). */ +function revokeAll({ image, cutout, original }: RetouchState) { + for (const url of new Set([image, cutout, original])) { + revokeBlobURL(url) + } +} + +/** Bumped per image and on reset, so a stale blend is thrown away. */ +let mixRun = 0 +/** Blending is slow on big photos: run one at a time, latest value wins. */ +let mixing = false + export const useRetouchStore = create((set, get) => ({ ...defaultRetouchState, - setImage(image: string) { - const previous = get().image - if (previous !== image) { - revokeBlobURL(previous) + setImage(image: string, original = '') { + const previous = get() + if (previous.image !== image || previous.original !== original) { + revokeAll(previous) } + mixRun++ // Keep the guide toggle across images; it is a view preference. - set({ ...defaultRetouchState, image, guides: get().guides }) + set({ + ...defaultRetouchState, + image, + cutout: image, + original, + guides: get().guides, + }) + }, + setRemoval(removal: number) { + set({ removal }) + if (mixing) { + return + } + const run = mixRun + mixing = true + const apply = async () => { + try { + // Re-read after each blend: the slider may have moved meanwhile. + for (;;) { + const { cutout, original, removal: wanted } = get() + const url = + original && wanted < 1 + ? await mixOriginalBackground(cutout, original, wanted) + : cutout + if (run !== mixRun) { + if (url !== cutout) revokeBlobURL(url) + return + } + const { image: previous } = get() + if (previous !== cutout && previous !== url) revokeBlobURL(previous) + set({ image: url }) + if (get().removal === wanted) return + } + } finally { + mixing = false + // A new image arrived mid-blend and its slider moved: blend it now. + if (run !== mixRun && get().original && get().removal < 1) { + get().setRemoval(get().removal) + } + } + } + apply().catch(() => { + // Keep the last good blend. + }) }, setBackground(background: RetouchBackground) { set({ background }) @@ -125,7 +196,8 @@ export const useRetouchStore = create((set, get) => ({ set({ guides: !get().guides }) }, reset() { - revokeBlobURL(get().image) + revokeAll(get()) + mixRun++ set(defaultRetouchState) }, })) diff --git a/packages/common/src/utils/headGuide.ts b/packages/common/src/utils/headGuide.ts index 5a3a93f..af5f0e0 100644 --- a/packages/common/src/utils/headGuide.ts +++ b/packages/common/src/utils/headGuide.ts @@ -66,14 +66,11 @@ export function createHeadGuideSVG( eyeMid !== null ? eyeMid - EYE_IN_HEAD * head : placement.crown const y = (fraction: number) => fraction * height const cx = width / 2 - const ellipse = (head: number) => { - const top = y(crownFor(head)) - const h = head * height - return stroke( + const ellipse = (top: number, h: number) => + stroke( (attrs) => `` ) - } const hline = (at: number) => stroke( (attrs) => @@ -132,6 +129,19 @@ export function createHeadGuideSVG( : hline(top)) + label(bottom - 4, 'eyes', 'end') } + // Head outlines. With crown or chin bands, the smallest head spans the + // inner edges of the bands and the largest the outer edges, so the outlines + // meet the marker lines; otherwise both hang from the anchored crown. + const hasChinBand = minChin > 0 || maxChin > 0 + const outlines = + hasCrownBand || hasChinBand + ? ellipse(crownBottomY, chinTopY - crownBottomY) + + (chinBottomY > chinTopY || crownBottomY > crownTopY + ? ellipse(crownTopY, chinBottomY - crownTopY) + : '') + : ellipse(y(crownFor(smallest)), smallest * height) + + (hasRange ? ellipse(y(crownFor(largest)), largest * height) : '') + const centerOffset = spec.rules?.maxCenterOffset ?? 0 const centerDrift = centerOffset > 0 ? y(centerOffset) : 0 @@ -141,8 +151,7 @@ export function createHeadGuideSVG( (hasCrownBand ? band(crownTopY, crownBottomY, 'rgba(80,220,140,0.2)') : '') + eyeBand + band(chinTopY, chinBottomY, 'rgba(80,220,140,0.2)') + - ellipse(smallest) + - (hasRange ? ellipse(largest) : '') + + outlines + vline(cx) + (centerDrift > 0 ? vline(cx - centerDrift, 0.6) + vline(cx + centerDrift, 0.6) diff --git a/packages/common/src/utils/image.ts b/packages/common/src/utils/image.ts index daf8ed6..bf077d9 100644 --- a/packages/common/src/utils/image.ts +++ b/packages/common/src/utils/image.ts @@ -82,6 +82,45 @@ export async function cropIDPhoto({ }) } +/** + * Blend part of the original background back into a cutout: the original + * photo drawn at `1 - removal` opacity under the cutout. At 1 the result is + * the plain cutout, at 0 the original photo. Both images must be the same + * crop. Returns a PNG blob URL. + */ +export async function mixOriginalBackground( + cutoutURL: string, + originalURL: string, + removal: number +): Promise { + const [cutout, original] = await Promise.all([ + loadImage(cutoutURL), + loadImage(originalURL), + ]) + const canvas = document.createElement('canvas') + canvas.width = cutout.naturalWidth || cutout.width + canvas.height = cutout.naturalHeight || cutout.height + const ctx = canvas.getContext('2d') + if (!ctx) { + throw new Error('Canvas 2D context unavailable') + } + ctx.globalAlpha = Math.min(Math.max(1 - removal, 0), 1) + ctx.drawImage(original, 0, 0, canvas.width, canvas.height) + ctx.globalAlpha = 1 + ctx.drawImage(cutout, 0, 0, canvas.width, canvas.height) + return new Promise((resolve, reject) => { + canvas.toBlob((file) => { + canvas.width = 0 + canvas.height = 0 + if (file) { + resolve(URL.createObjectURL(file)) + } else { + reject(new Error('failed')) + } + }, 'image/png') + }) +} + /** Render one finished ID photo (background + adjustments) onto a canvas. */ export async function renderIDPhotoCanvas({ image: imageURL, From 1f156030391e06d7a04b23f22b88e4cc11c54b5c Mon Sep 17 00:00:00 2001 From: whcrg <46355292+whcrg@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:09:11 +0200 Subject: [PATCH 3/4] Line up the blended original with the cutout The crop area is measured on the cutout, which background removal may return at a different resolution from the original. Scale the original to the cutout's size before cropping it for the blend, and skip the blend (hiding the slider) when the two don't have the same shape. Co-Authored-By: Claude Opus 5.5 --- packages/common/src/pages/cropper/index.tsx | 11 +++-- packages/common/src/utils/image.ts | 50 +++++++++++++++++++-- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/packages/common/src/pages/cropper/index.tsx b/packages/common/src/pages/cropper/index.tsx index fd4ca13..6764fe6 100644 --- a/packages/common/src/pages/cropper/index.tsx +++ b/packages/common/src/pages/cropper/index.tsx @@ -22,7 +22,7 @@ import { useSegementStore, useSelectedSpec, } from '../../stores' -import { cropIDPhoto } from '../../utils' +import { cropIDPhoto, cropMatchingOriginal } from '../../utils' import { CloseButton, ConfigContext, @@ -121,10 +121,15 @@ export default function Cropper() { image: sourceImage, }) // The same crop of the original lets retouching blend some of the - // original background back in. + // original background back in. The area was measured on the cutout, + // which may not share the original's resolution. const original = useOriginal ? undefined - : await cropIDPhoto({ ...cropOptions, image }) + : await cropMatchingOriginal({ + ...cropOptions, + image, + cutout: result, + }) useAppStore.getState().retouch(idPhoto, original) } catch (error) { enqueueSnackbar( diff --git a/packages/common/src/utils/image.ts b/packages/common/src/utils/image.ts index bf077d9..4f099a8 100644 --- a/packages/common/src/utils/image.ts +++ b/packages/common/src/utils/image.ts @@ -27,6 +27,7 @@ export async function cropIDPhoto({ flipHorizontal = false, flipVertical = false, resolution, + sourceSize, }: { image: string area: CropArea @@ -37,11 +38,21 @@ export async function cropIDPhoto({ width: number height: number } + /** + * Treat the image as if it were this size: for cropping one image with an + * area measured on another of the same shape but a different resolution. + */ + sourceSize?: { + width: number + height: number + } }): Promise { if (!(area.width > 0) || !(area.height > 0)) { throw new Error('Empty crop area') } const image = await loadImage(imageURL) + const imageWidth = sourceSize?.width || image.naturalWidth || image.width + const imageHeight = sourceSize?.height || image.naturalHeight || image.height // Supersample for the retouch step, but keep the canvas within the limits // mobile browsers can allocate. const output = getCropOutputSize(resolution) @@ -57,8 +68,8 @@ export async function cropIDPhoto({ // One transform straight from the source: no full-size rotated intermediate. ctx.setTransform( ...getCropTransform({ - imageWidth: image.naturalWidth || image.width, - imageHeight: image.naturalHeight || image.height, + imageWidth, + imageHeight, rotation, flipHorizontal, flipVertical, @@ -67,7 +78,7 @@ export async function cropIDPhoto({ outputHeight: output.height, }) ) - ctx.drawImage(image, 0, 0) + ctx.drawImage(image, 0, 0, imageWidth, imageHeight) return new Promise((resolve, reject) => { canvas.toBlob((file) => { // Release the backing store early; large canvases add up on iOS. @@ -82,6 +93,39 @@ export async function cropIDPhoto({ }) } +/** + * Crop the original photo to match a crop of its cutout, whose area was + * measured on the cutout. Background removal may return a different + * resolution, so the original is scaled to the cutout's size first. Returns + * undefined when the two do not have the same shape (e.g. one was rotated + * by its EXIF orientation and the other not), as they cannot be lined up. + */ +export async function cropMatchingOriginal({ + image: originalURL, + cutout: cutoutURL, + ...options +}: Omit[0], 'sourceSize'> & { + cutout: string +}): Promise { + const [original, cutout] = await Promise.all([ + loadImage(originalURL), + loadImage(cutoutURL), + ]) + const sourceSize = { + width: cutout.naturalWidth || cutout.width, + height: cutout.naturalHeight || cutout.height, + } + const originalAspect = + (original.naturalWidth || original.width) / + (original.naturalHeight || original.height) + if ( + Math.abs(sourceSize.width / sourceSize.height / originalAspect - 1) > 0.01 + ) { + return undefined + } + return cropIDPhoto({ ...options, image: originalURL, sourceSize }) +} + /** * Blend part of the original background back into a cutout: the original * photo drawn at `1 - removal` opacity under the cutout. At 1 the result is From 6afd36faba1906f8ed40450105271e4e2014f0f8 Mon Sep 17 00:00:00 2001 From: whcrg <46355292+whcrg@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:25:46 +0200 Subject: [PATCH 4/4] Save size-limited specs as JPEG within the limit Specs with an upload limit (rules.size, e.g. Finland online: 250 KB) were always saved as PNG and only warned when too large. Save them as JPEG instead, at the highest quality (0.95 down to 0.5, by binary search) that fits the limit, on white where the background is transparent. Online submissions such as the Finnish Police's expect JPEG anyway. The warning remains for the rare photo that still does not fit at the lowest quality. Other specs stay PNG with print density. Co-Authored-By: Claude Opus 5.5 --- packages/common/src/pages/retouch/index.tsx | 9 +++- packages/common/src/utils/compliance.test.ts | 23 +++++++++ packages/common/src/utils/compliance.ts | 31 ++++++++++++ packages/common/src/utils/image.ts | 51 ++++++++++++++++---- 4 files changed, 103 insertions(+), 11 deletions(-) diff --git a/packages/common/src/pages/retouch/index.tsx b/packages/common/src/pages/retouch/index.tsx index c242225..aa6abb4 100644 --- a/packages/common/src/pages/retouch/index.tsx +++ b/packages/common/src/pages/retouch/index.tsx @@ -61,16 +61,21 @@ export default function ImageRetouch(props: ImageRetouchProps) { background, adjustment, resolution: spec.dimension, + // Specs with an upload limit are saved as a JPEG that fits it. + maxKB: spec.rules?.size, }) if (!idPhoto) { return } - // Warn (don't block) when the PNG exceeds the authority's file limit. + // Warn (don't block) if even the lowest quality is over the limit. const excess = getFileSizeExcess(idPhoto.size, spec) if (config.savePhoto) { await config.savePhoto(idPhoto) } else { - downloadBlob(idPhoto, 'idphoto.png') + downloadBlob( + idPhoto, + idPhoto.type === 'image/jpeg' ? 'idphoto.jpg' : 'idphoto.png' + ) } enqueueSnackbar( intl.formatMessage({ id: 'saveSucceeded', defaultMessage: 'Saved.' }), diff --git a/packages/common/src/utils/compliance.test.ts b/packages/common/src/utils/compliance.test.ts index f3896b0..e52f43c 100644 --- a/packages/common/src/utils/compliance.test.ts +++ b/packages/common/src/utils/compliance.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + encodeWithinSize, getFileSizeExcess, hasGradientBackground, isNonNeutralAdjustment, @@ -61,3 +62,25 @@ describe('getFileSizeExcess', () => { expect(getFileSizeExcess(10, undefined)).toBeNull() }) }) + +describe('encodeWithinSize', () => { + // A fake encoder whose output grows with quality: 1000 bytes per 0.01. + const encode = async (quality: number) => + new Blob([new Uint8Array(Math.round(quality * 100_000))]) + + it('keeps the top quality when it already fits', async () => { + const blob = await encodeWithinSize(encode, 200_000) + expect(blob.size).toBe(95_000) + }) + + it('finds the highest quality under the limit', async () => { + const blob = await encodeWithinSize(encode, 70_000) + expect(blob.size).toBeLessThanOrEqual(70_000) + expect(blob.size).toBeGreaterThan(65_000) + }) + + it('returns the smallest file when nothing fits', async () => { + const blob = await encodeWithinSize(encode, 10_000) + expect(blob.size).toBe(50_000) + }) +}) diff --git a/packages/common/src/utils/compliance.ts b/packages/common/src/utils/compliance.ts index f5b268a..09d7812 100644 --- a/packages/common/src/utils/compliance.ts +++ b/packages/common/src/utils/compliance.ts @@ -46,3 +46,34 @@ export function getFileSizeExcess( } return { actualKB: Math.ceil(bytes / 1024), maxKB } } + +/** + * Encode at the highest quality whose output is at most `maxBytes`, by + * binary search over `minQuality..maxQuality`. `encode` turns a quality + * (0..1) into a file. If even `minQuality` is too large, that smallest file + * is returned and the caller can still warn about it. + */ +export async function encodeWithinSize( + encode: (quality: number) => Promise, + maxBytes: number, + { minQuality = 0.5, maxQuality = 0.95, steps = 6 } = {} +): Promise { + const best = await encode(maxQuality) + if (best.size <= maxBytes) { + return best + } + let lo = minQuality + let hi = maxQuality + let fit: Blob | null = null + for (let i = 0; i < steps; i++) { + const quality = (lo + hi) / 2 + const blob = await encode(quality) + if (blob.size <= maxBytes) { + fit = blob + lo = quality + } else { + hi = quality + } + } + return fit ?? (await encode(minQuality)) +} diff --git a/packages/common/src/utils/image.ts b/packages/common/src/utils/image.ts index 4f099a8..417e587 100644 --- a/packages/common/src/utils/image.ts +++ b/packages/common/src/utils/image.ts @@ -6,6 +6,7 @@ import { renderGLFXAdjustment, } from './canvas' import { withPngDensity } from './png' +import { encodeWithinSize } from './compliance' import { CropArea, getCropOutputSize, getCropTransform } from './crop' export type { IDPhotoDimension } @@ -207,23 +208,37 @@ export async function renderIDPhotoCanvas({ return canvas } -function canvasToPNG(canvas: HTMLCanvasElement): Promise { +function canvasToBlob( + canvas: HTMLCanvasElement, + type = 'image/png', + quality?: number +): Promise { return new Promise((resolve, reject) => { - canvas.toBlob((file) => { - if (file) { - resolve(file) - } else { - reject(new Error('failed')) - } - }, 'image/png') + canvas.toBlob( + (file) => { + if (file) { + resolve(file) + } else { + reject(new Error('failed')) + } + }, + type, + quality + ) }) } +/** + * Render the finished ID photo. With `maxKB` (a spec's upload limit) it is a + * JPEG at the highest quality that fits, as online submissions expect; + * otherwise a PNG carrying the print density. + */ export async function createIDPhoto({ image, background, adjustment, resolution, + maxKB, }: { image: string background: RetouchBackground @@ -233,6 +248,7 @@ export async function createIDPhoto({ height: number dpi?: number } + maxKB?: number }): Promise { const canvas = await renderIDPhotoCanvas({ image, @@ -243,6 +259,23 @@ export async function createIDPhoto({ if (!canvas) { return null } - const blob = await canvasToPNG(canvas) + if (maxKB && maxKB > 0) { + // JPEG has no transparency: put a transparent background on white. + const flat = document.createElement('canvas') + flat.width = canvas.width + flat.height = canvas.height + const ctx = flat.getContext('2d') + if (!ctx) { + return null + } + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, flat.width, flat.height) + ctx.drawImage(canvas, 0, 0) + return encodeWithinSize( + (quality) => canvasToBlob(flat, 'image/jpeg', quality), + maxKB * 1024 + ) + } + const blob = await canvasToBlob(canvas) return resolution.dpi ? withPngDensity(blob, resolution.dpi) : blob }