Skip to content
Open
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
29 changes: 29 additions & 0 deletions research-image-integrity-assistant/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Research Image Integrity Assistant

Self-contained AI-powered research assistant slice for issue #16. It gives reviewers a deterministic pre-submission image-integrity packet before manuscript figures are released for peer review.

## What It Checks

- Duplicated image panels across manuscript figures using synthetic perceptual hashes.
- Missing raw-image provenance, including absent raw-image IDs and checksum metadata.
- Missing processing histories for crop, contrast, compositing, and adjustment review.
- Scale-bar inconsistencies between declared figure labels and raw pixel-size metadata.

## Outputs

- `reports/image-integrity-packet.json`: structured reviewer decisions and findings.
- `reports/reviewer-report.md`: readable reviewer report for each synthetic scenario.
- `reports/summary.svg`: visual summary of approve, response, and hold decisions.
- `reports/demo.mp4`: short demo artifact for Algora review.

## Local Verification

```bash
node research-image-integrity-assistant/test.js
node research-image-integrity-assistant/demo.js
node --check research-image-integrity-assistant/index.js
node --check research-image-integrity-assistant/test.js
node --check research-image-integrity-assistant/demo.js
```

The module is dependency-free, uses synthetic data only, and makes no network calls.
53 changes: 53 additions & 0 deletions research-image-integrity-assistant/demo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
const fs = require('node:fs');
const path = require('node:path');

const {evaluateImageIntegrity, buildReviewerReport} = require('./index');
const {scenarios} = require('./sample-data');

const reportsDir = path.join(__dirname, 'reports');
fs.mkdirSync(reportsDir, {recursive: true});

const evaluations = scenarios.map((scenario) => ({
scenario: scenario.name,
...evaluateImageIntegrity(scenario),
}));

const reviewerReport = evaluations.map(buildReviewerReport).join('\n---\n');
const packetJson = JSON.stringify(evaluations, null, 2);
const approved = evaluations.filter((item) => item.decision === 'approved').length;
const response = evaluations.filter((item) => item.decision === 'needs-author-response').length;
const hold = evaluations.filter((item) => item.decision === 'hold-for-review').length;
const findings = evaluations.reduce((sum, item) => sum + item.findings.length, 0);

const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<rect width="960" height="540" fill="#111827"/>
<text x="48" y="72" fill="#f9fafb" font-family="Arial, sans-serif" font-size="34" font-weight="700">Research Image Integrity Assistant</text>
<text x="48" y="112" fill="#c7d2fe" font-family="Arial, sans-serif" font-size="18">Synthetic reviewer packet for manuscript figure forensics</text>
<g transform="translate(48 158)">
<rect width="250" height="154" rx="10" fill="#065f46"/>
<text x="24" y="48" fill="#d1fae5" font-family="Arial, sans-serif" font-size="20" font-weight="700">Approved</text>
<text x="24" y="108" fill="#ecfdf5" font-family="Arial, sans-serif" font-size="58" font-weight="700">${approved}</text>
</g>
<g transform="translate(354 158)">
<rect width="250" height="154" rx="10" fill="#92400e"/>
<text x="24" y="48" fill="#fef3c7" font-family="Arial, sans-serif" font-size="20" font-weight="700">Author Response</text>
<text x="24" y="108" fill="#fffbeb" font-family="Arial, sans-serif" font-size="58" font-weight="700">${response}</text>
</g>
<g transform="translate(660 158)">
<rect width="250" height="154" rx="10" fill="#991b1b"/>
<text x="24" y="48" fill="#fee2e2" font-family="Arial, sans-serif" font-size="20" font-weight="700">Hold Review</text>
<text x="24" y="108" fill="#fef2f2" font-family="Arial, sans-serif" font-size="58" font-weight="700">${hold}</text>
</g>
<text x="48" y="382" fill="#e5e7eb" font-family="Arial, sans-serif" font-size="22">Checks: duplicate panels, raw provenance, processing logs, scale-bar metadata</text>
<text x="48" y="424" fill="#d1d5db" font-family="Arial, sans-serif" font-size="18">Reviewer findings: ${findings}. Outputs: JSON packet, Markdown report, SVG summary, MP4 artifact.</text>
<text x="48" y="478" fill="#9ca3af" font-family="Arial, sans-serif" font-size="16">Synthetic data only. No patient data, private images, secrets, external APIs, or network calls.</text>
</svg>
`;

fs.writeFileSync(path.join(reportsDir, 'image-integrity-packet.json'), `${packetJson}\n`);
fs.writeFileSync(path.join(reportsDir, 'reviewer-report.md'), reviewerReport);
fs.writeFileSync(path.join(reportsDir, 'summary.svg'), svg);

console.log(`Wrote ${evaluations.length} image-integrity evaluations to ${reportsDir}`);
console.log(`Decision counts: approved=${approved}, response=${response}, hold=${hold}`);
console.log(`Reviewer findings: ${findings}`);
198 changes: 198 additions & 0 deletions research-image-integrity-assistant/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
function normalizeList(value) {
return Array.isArray(value) ? value : [];
}

function roundMicrometers(value) {
return Math.round(value * 100) / 100;
}

function panelReference(figure, panel) {
return `${figure.id}:${panel.id}`;
}

function imageIntegrityAction(type, target, reason) {
return {type, target, reason};
}

function severityCounts(findings) {
return findings.reduce((counts, finding) => {
counts[finding.severity] = (counts[finding.severity] || 0) + 1;
return counts;
}, {});
}

function evaluateImageIntegrity(input) {
const figures = normalizeList(input.figures);
const rawImages = normalizeList(input.rawImages);
const processingLogs = normalizeList(input.processingLogs);
const rawById = new Map(rawImages.map((image) => [image.id, image]));
const logById = new Map(processingLogs.map((log) => [log.id, log]));
const findings = [];
const requiredActions = [];
const panelsByHash = new Map();
let panelCount = 0;

for (const figure of figures) {
for (const panel of normalizeList(figure.panels)) {
panelCount += 1;
if (panel.perceptualHash) {
const panelSet = panelsByHash.get(panel.perceptualHash) || [];
panelSet.push({figure, panel});
panelsByHash.set(panel.perceptualHash, panelSet);
}
}
}

for (const [perceptualHash, panelSet] of panelsByHash.entries()) {
if (panelSet.length > 1) {
const panels = panelSet.map(({figure, panel}) => panelReference(figure, panel));
findings.push({
type: 'duplicate-panel',
severity: 'critical',
panels,
perceptualHash,
message: `Panels share perceptual hash ${perceptualHash}`,
});
requiredActions.push(imageIntegrityAction(
'verify_panel_uniqueness',
panels.join(', '),
'duplicated image panels need raw-data review before peer-review release'
));
}
}

for (const figure of figures) {
for (const panel of normalizeList(figure.panels)) {
const ref = panelReference(figure, panel);
const rawImage = rawById.get(panel.rawImageId);
const processingLog = logById.get(panel.processingLogId);

if (!rawImage) {
findings.push({
type: 'missing-raw-provenance',
severity: 'critical',
panel: ref,
rawImageId: panel.rawImageId || '',
message: `${ref} lacks linked raw image provenance`,
});
requiredActions.push(imageIntegrityAction(
'attach_raw_image_provenance',
ref,
'reviewers need raw-image checksum, capture time, and pixel-size metadata'
));
}

if (!processingLog) {
findings.push({
type: 'missing-processing-log',
severity: 'critical',
panel: ref,
processingLogId: panel.processingLogId || '',
message: `${ref} lacks a linked processing history`,
});
requiredActions.push(imageIntegrityAction(
'attach_processing_log',
ref,
'reviewers need auditable crop, contrast, and adjustment history'
));
}

if (rawImage && panel.scaleBarPixels > 0 && panel.scaleBarMicrometers > 0) {
const expectedMicrometers = roundMicrometers(panel.scaleBarPixels * rawImage.pixelSizeMicrometers);
const declaredMicrometers = roundMicrometers(panel.scaleBarMicrometers);
const relativeDifference = expectedMicrometers === 0
? 0
: Math.abs(declaredMicrometers - expectedMicrometers) / expectedMicrometers;

if (relativeDifference > 0.05) {
findings.push({
type: 'scale-bar-mismatch',
severity: 'major',
panel: ref,
expectedMicrometers,
declaredMicrometers,
pixelSizeMicrometers: rawImage.pixelSizeMicrometers,
scaleBarPixels: panel.scaleBarPixels,
message: `${ref} declares ${declaredMicrometers} um but metadata implies ${expectedMicrometers} um`,
});
requiredActions.push(imageIntegrityAction(
'reconcile_scale_bar_metadata',
ref,
'declared scale bar must match raw pixel-size metadata before reviewer approval'
));
}
}
}
}

const counts = severityCounts(findings);
const criticalCount = counts.critical || 0;
const majorCount = counts.major || 0;
const minorCount = counts.minor || 0;
const decision = criticalCount > 0
? 'hold-for-review'
: findings.length > 0
? 'needs-author-response'
: 'approved';
const integrityScore = Math.max(0, 100 - criticalCount * 35 - majorCount * 20 - minorCount * 10);

return {
manuscriptId: input.manuscriptId,
generatedAt: input.generatedAt,
decision,
integrityScore,
findings,
requiredActions,
summary: {
figureCount: figures.length,
panelCount,
rawImageCount: rawImages.length,
processingLogCount: processingLogs.length,
duplicatePanelGroups: findings.filter((finding) => finding.type === 'duplicate-panel').length,
missingRawProvenance: findings.filter((finding) => finding.type === 'missing-raw-provenance').length,
missingProcessingLogs: findings.filter((finding) => finding.type === 'missing-processing-log').length,
scaleBarMismatches: findings.filter((finding) => finding.type === 'scale-bar-mismatch').length,
severityCounts: counts,
},
};
}

function buildReviewerReport(result) {
const lines = [
'# Research Image Integrity Assistant Report',
'',
`Manuscript: ${result.manuscriptId}`,
`Generated: ${result.generatedAt}`,
`Decision: ${result.decision}`,
`Integrity score: ${result.integrityScore}`,
'',
'## Packet Summary',
'',
`Figures: ${result.summary.figureCount}`,
`Panels: ${result.summary.panelCount}`,
`Raw images: ${result.summary.rawImageCount}`,
`Processing logs: ${result.summary.processingLogCount}`,
`Duplicate panel groups: ${result.summary.duplicatePanelGroups}`,
`Scale-bar mismatches: ${result.summary.scaleBarMismatches}`,
`Findings: ${result.findings.length}`,
'',
'## Findings',
'',
...(result.findings.length
? result.findings.map((finding) => `- ${finding.severity}: ${finding.type} - ${finding.message}`)
: ['- None']),
'',
'## Required Actions',
'',
...(result.requiredActions.length
? result.requiredActions.map((action) => `- ${action.type}: ${action.target} (${action.reason})`)
: ['- None']),
'',
];
return lines.join('\n');
}

module.exports = {
evaluateImageIntegrity,
buildReviewerReport,
};
Binary file not shown.
Loading