gongxiangp2/lib/ai-model-multireference.ts

72 lines
6.3 KiB
TypeScript

import { randomInt } from 'node:crypto'
import sharp from 'sharp'
import { AiModelError } from './ai-model-access'
import { createVerificationChallenge, readVerificationChallenge, referenceLayoutMatches, verificationReferenceImage, type VerificationChallenge } from './ai-model-verification-output'
export interface MultiReferenceChallenge { version: 1; references: VerificationChallenge[] }
/** Distinct, ordered pairs prevent a model from passing by copying reference #1. */
export function createMultiReferenceChallenge(count: number): MultiReferenceChallenge {
if (!Number.isInteger(count) || count < 2 || count > 8) throw new AiModelError('多图测试数量须为 2 至 8 张', 400)
const references: VerificationChallenge[] = []
while (references.length < count) {
const next = createVerificationChallenge()
if (!references.some(value => value.topLeft === next.topLeft && value.bottomRight === next.bottomRight)) references.push(next)
}
// The generation is already random; shuffle the final order independently too.
for (let i = references.length - 1; i > 0; i--) { const j = randomInt(i + 1); [references[i], references[j]] = [references[j], references[i]] }
return { version: 1, references }
}
export function readMultiReferenceChallenge(value: unknown): MultiReferenceChallenge {
const raw = value as MultiReferenceChallenge | null
if (raw?.version !== 1 || !Array.isArray(raw.references) || raw.references.length < 2 || raw.references.length > 8 || raw.references.some(value => !value || typeof value !== 'object')) throw new AiModelError('多图测试检查点无效,不能重建或猜测参考图', 409)
const references = raw.references.map(readVerificationChallenge)
if (new Set(references.map(value => `${value.topLeft}/${value.bottomRight}`)).size !== references.length) throw new AiModelError('多图测试参考图重复,检查点无效', 409)
return { version: 1, references }
}
export function multiReferenceGrid(count: number) {
const columns = count <= 4 ? 2 : 4
return { columns, rows: Math.ceil(count / columns) }
}
export function multiReferencePrompt(challenge: MultiReferenceChallenge): string {
const count = challenge.references.length, { columns, rows } = multiReferenceGrid(count)
return `你收到 ${count} 张独立参考图。必须逐张读取,将全部参考图按原始输入顺序排成 ${columns} 列 ${rows} 行的等大方格拼图:从左到右、从上到下,每格仅对应一张参考图。每格内严格保留该参考图两个方块各自的原始颜色、形状和左上/右下相对位置。每个色块直接采样对应参考图的原色,禁止改色或调色。所有格子等宽等高;纯白背景,无外边框、无格间空隙,未用的格子留白。平面重绘,不要文字、序号、标志、阴影、纹理、渐变或光照。不能重复第一张图代替其余参考图。`
}
export async function multiReferenceImages(challenge: MultiReferenceChallenge) {
return Promise.all(challenge.references.map(async reference => ({ buffer: await verificationReferenceImage(reference), mimeType: 'image/png' as const })))
}
/** Verify each tile against its own hidden challenge, never the adapter's claims. */
export async function validateMultiReferenceOutput(image: Buffer | undefined, challenge: MultiReferenceChallenge): Promise<Record<string, unknown>> {
if (!image) throw new AiModelError('多图测试未返回图片', 422, 'verification_output_invalid')
// Output padding is a layout choice, not evidence that an input was ignored.
// Normalize only the exterior white margin; preserve tile order and all pixels
// inside the content so missing, repeated or swapped references still fail.
const content = await sharp(image, { limitInputPixels: 40_000_000 }).flatten({ background: '#fff' }).trim({ background: '#fff', threshold: 25 }).png().toBuffer()
const metadata = await sharp(content, { limitInputPixels: 40_000_000 }).metadata()
const storedMetadata = await sharp(image, { limitInputPixels: 40_000_000 }).metadata()
const count = challenge.references.length, intended = multiReferenceGrid(count), square = Math.ceil(Math.sqrt(count))
// Image models may preserve square reference panels instead of stretching them.
// Test bounded row-major layouts; every input must match its own distinct tile
// in one complete layout. We never match a color from a different input's tile.
const layouts = [intended, { columns: square, rows: square }, { columns: intended.columns, rows: intended.columns }]
.filter((grid, index, list) => list.findIndex(value => value.columns === grid.columns && value.rows === grid.rows) === index)
if (!metadata.width || !metadata.height || metadata.width < intended.columns * 64 || metadata.height < intended.rows * 64) throw new AiModelError('多图测试返回的图片无效或尺寸过小', 422, 'verification_output_invalid')
let matches: boolean[] = [], matchedGrid: { columns: number; rows: number } | null = null
for (const { columns, rows } of layouts) {
const attempt: boolean[] = []
for (let index = 0; index < count; index++) {
const col = index % columns, row = Math.floor(index / columns)
const left = Math.floor(col * metadata.width / columns), top = Math.floor(row * metadata.height / rows)
const right = Math.floor((col + 1) * metadata.width / columns), bottom = Math.floor((row + 1) * metadata.height / rows)
const tile = await sharp(content, { limitInputPixels: 40_000_000 }).extract({ left, top, width: right - left, height: bottom - top }).png().toBuffer()
attempt.push(await referenceLayoutMatches(tile, challenge.references[index]))
}
if (!matches.length || attempt.filter(Boolean).length > matches.filter(Boolean).length) matches = attempt
if (attempt.every(Boolean)) { matchedGrid = { columns, rows }; matches = attempt; break }
}
if (!matchedGrid) throw new AiModelError(`多图测试未通过:第 ${matches.flatMap((value, index) => value ? [] : [index + 1]).join('、')} 张参考图的颜色或位置未保留`, 422, 'verification_output_invalid')
return { referenceImageCount: challenge.references.length, allReferenceImagesMatched: true, referenceMatches: matches,
referenceImageAccepted: true, referenceLayoutMatched: true, imageDecoded: true, referenceGrid: matchedGrid,
width: storedMetadata.width, height: storedMetadata.height, bytes: image.length, multiReferenceRuleVersion: 1 }
}