@jjlmoya/utils-forensic-science 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/category/index.ts +5 -1
- package/src/entries.ts +9 -1
- package/src/index.ts +2 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/fire-pattern-origin-analyzer/bibliography.astro +6 -0
- package/src/tool/fire-pattern-origin-analyzer/bibliography.ts +12 -0
- package/src/tool/fire-pattern-origin-analyzer/component.astro +196 -0
- package/src/tool/fire-pattern-origin-analyzer/entry.ts +32 -0
- package/src/tool/fire-pattern-origin-analyzer/fire-pattern-origin-analyzer.css +681 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/de.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/en.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/es.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/fr.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/id.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/it.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/ja.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/ko.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/nl.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/pl.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/pt.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/ru.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/sv.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/tr.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/zh.ts +246 -0
- package/src/tool/fire-pattern-origin-analyzer/index.ts +11 -0
- package/src/tool/fire-pattern-origin-analyzer/logic.ts +123 -0
- package/src/tool/fire-pattern-origin-analyzer/render.ts +159 -0
- package/src/tool/fire-pattern-origin-analyzer/seo.astro +15 -0
- package/src/tool/fire-pattern-origin-analyzer/view-bindings.ts +171 -0
- package/src/tool/fire-pattern-origin-analyzer/view-model.ts +244 -0
- package/src/tool/fire-pattern-origin-analyzer/view.ts +30 -0
- package/src/tool/forensic-toolmark-striation-matcher/bibliography.astro +6 -0
- package/src/tool/forensic-toolmark-striation-matcher/bibliography.ts +16 -0
- package/src/tool/forensic-toolmark-striation-matcher/component.astro +121 -0
- package/src/tool/forensic-toolmark-striation-matcher/entry.ts +32 -0
- package/src/tool/forensic-toolmark-striation-matcher/forensic-toolmark-striation-matcher.css +580 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/de.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/en.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/es.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/fr.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/id.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/it.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/ja.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/ko.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/nl.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/pl.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/pt.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/ru.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/sv.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/tr.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/zh.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/index.ts +11 -0
- package/src/tool/forensic-toolmark-striation-matcher/logic.ts +71 -0
- package/src/tool/forensic-toolmark-striation-matcher/renderer.ts +237 -0
- package/src/tool/forensic-toolmark-striation-matcher/seo.astro +15 -0
- package/src/tool/forensic-toolmark-striation-matcher/view.ts +291 -0
- package/src/tools.ts +5 -1
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import type { ToolLocaleContent } from '../../../types';
|
|
2
|
+
import { bibliography } from '../bibliography';
|
|
3
|
+
|
|
4
|
+
const slug = 'forensic-toolmark-striation-matcher';
|
|
5
|
+
const title = '法医工具痕迹线条比对器';
|
|
6
|
+
const description = '上传已知和待检的工具痕迹图像,在分屏对比显微镜中进行对齐,并通过视觉控制检查条纹线条的连续性。';
|
|
7
|
+
|
|
8
|
+
const howTo = [
|
|
9
|
+
{
|
|
10
|
+
name: '加载比对图像',
|
|
11
|
+
text: '从您自己的案卷材料、笔记或训练集中上传已知测试痕迹图像和待检痕迹图像。',
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: '移动对比分屏线',
|
|
15
|
+
text: '在光学观察器上拖动分屏线以露出更多已知或待检的痕迹,同时将两个样本保持在同一个视场中。',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: '调整偏移、旋转、缩放和对比度',
|
|
19
|
+
text: '使用微米级控制平移和旋转待检图像,然后调整缩放、对比度和亮度,使微弱的线条保持可见。',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: '导出对比图',
|
|
23
|
+
text: '视觉对齐完成后,导出 PNG 对比视图以供讨论、课堂评估或案卷笔记插图使用。',
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const faq = [
|
|
28
|
+
{
|
|
29
|
+
question: '什么是法医工具痕迹比对?',
|
|
30
|
+
answer: '法医工具痕迹比对是指对工具接触、切割、刮擦、撬动或压缩另一个表面时产生的痕迹进行检验。检验员比对检验件和已知测试痕迹之间的种类特征、痕迹质量以及微观线条细节。',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
question: '这个在线工具能确定留下痕迹的准确工具吗?',
|
|
34
|
+
answer: '不能。本工作空间仅用于视觉对齐和教育。它不计算物证溯源、概率、误差率或自动化的法医鉴定结论。',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
question: '为什么偏移、旋转和缩放在工具痕迹比对中很重要?',
|
|
38
|
+
answer: '工具角度、压力、比例、相机位置和侧向放置的细微差异可能使相似的线条图案显得未对齐。偏移、旋转和缩放控制有助于将两张图像置于可比的观察位置。',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
question: '哪些图像最适合进行工具痕迹的视觉对齐?',
|
|
42
|
+
answer: '使用清晰、光照良好且尽可能垂直拍摄的图像,且痕迹区域有足够的重叠。避免严重模糊、极端反光、透视变形,或线条区域过短或受损而无法比对的图像。',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
question: '用户在对齐两张工具痕迹图像后应该记录什么?',
|
|
46
|
+
answer: '记录每张图像的来源、是待检还是已知、所用的对齐设置、应用的任何图像调整、可见的一致或不一致区域以及图像质量的局限性。',
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
export const content: ToolLocaleContent = {
|
|
51
|
+
slug,
|
|
52
|
+
title,
|
|
53
|
+
description,
|
|
54
|
+
ui: {
|
|
55
|
+
viewerAria: '已知和待检工具痕迹线条的分屏对比显微镜视图',
|
|
56
|
+
splitLabel: '移动对比分屏线',
|
|
57
|
+
units: '偏移单位系统',
|
|
58
|
+
metric: '公制',
|
|
59
|
+
imperial: '英制',
|
|
60
|
+
toolProfile: '工具轮廓',
|
|
61
|
+
loadImages: '1. 加载',
|
|
62
|
+
alignImage: '2. 对齐待检图像',
|
|
63
|
+
adjustImage: '3. 微调可见度',
|
|
64
|
+
outputControls: '输出控制',
|
|
65
|
+
demoOptions: '演示选项',
|
|
66
|
+
uploadKnown: '已知/测试图像',
|
|
67
|
+
uploadQuestioned: '待检图像',
|
|
68
|
+
noFile: '未加载图像',
|
|
69
|
+
emptyValue: '-',
|
|
70
|
+
zeroPercent: '0%',
|
|
71
|
+
screwdriver: '螺丝刀尖',
|
|
72
|
+
prybar: '撬棍边缘',
|
|
73
|
+
boltCutter: '断线钳口',
|
|
74
|
+
offset: '水平偏移',
|
|
75
|
+
rotation: '角旋转',
|
|
76
|
+
zoom: '图像缩放',
|
|
77
|
+
contrast: '对比度',
|
|
78
|
+
brightness: '亮度',
|
|
79
|
+
microns: '微米',
|
|
80
|
+
thousandths: '千分之一英寸',
|
|
81
|
+
degrees: '度',
|
|
82
|
+
correlation: '模式 / 得分',
|
|
83
|
+
verdictStrong: '强对齐',
|
|
84
|
+
verdictPartial: '部分对齐',
|
|
85
|
+
verdictWeak: '弱对齐',
|
|
86
|
+
center: '居中',
|
|
87
|
+
fineTip: '细微拉曳线',
|
|
88
|
+
wideEdge: '宽压印痕',
|
|
89
|
+
crushJaw: '成对挤压脊',
|
|
90
|
+
grid: '网格',
|
|
91
|
+
exportImage: '导出视图',
|
|
92
|
+
exportFilename: 'toolmark-comparison-view.png',
|
|
93
|
+
phaseFit: '相位拟合',
|
|
94
|
+
rotationFit: '旋转拟合',
|
|
95
|
+
knownMark: '已知测试痕',
|
|
96
|
+
questionedMark: '待检痕迹',
|
|
97
|
+
reliefGraph: '数字化微观表面起伏',
|
|
98
|
+
visualMode: '视觉',
|
|
99
|
+
visualVerdict: '人工比对',
|
|
100
|
+
visualInterpretation: '仅限视觉对齐。不计算自动化的法医鉴定结论。',
|
|
101
|
+
interpMatch: '演示预览。上传图像以对比真实痕迹。',
|
|
102
|
+
interpPossible: '演示预览。调整对齐或上传图像。',
|
|
103
|
+
interpMismatch: '演示预览。重新居中或上传图像。',
|
|
104
|
+
disclaimer: '仅限视觉工作空间。',
|
|
105
|
+
},
|
|
106
|
+
seo: [
|
|
107
|
+
{
|
|
108
|
+
type: 'title',
|
|
109
|
+
text: '在线法医工具痕迹对比显微镜',
|
|
110
|
+
level: 2,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
type: 'diagnostic',
|
|
114
|
+
variant: 'info',
|
|
115
|
+
icon: 'mdi:tools',
|
|
116
|
+
badge: '视觉工作区',
|
|
117
|
+
title: '在浏览器中对比待检和已知的工具痕迹图像',
|
|
118
|
+
html: '此工具痕迹对比工作空间允许您上传两张图像,将它们置于分屏比对显微镜布局中,并调整待检痕迹,直到可以并排检查条纹线、擦痕、压痕或微观线条。它专为法医学学生、讲师、调查员和法律团队设计,帮助他们在无需安装专业显微镜软件的情况下,清晰地解释<strong>已知与待检工具痕迹的比对</strong>。',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
type: 'stats',
|
|
122
|
+
columns: 4,
|
|
123
|
+
items: [
|
|
124
|
+
{ value: '2次上传', label: '已知和待检图像', icon: 'mdi:image-plus' },
|
|
125
|
+
{ value: '分屏视图', label: '比对显微镜布局', icon: 'mdi:compare-horizontal' },
|
|
126
|
+
{ value: '对齐', label: '偏移、旋转和缩放控制', icon: 'mdi:axis-arrow' },
|
|
127
|
+
{ value: '导出 PNG', label: '保存对比视图', icon: 'mdi:download' },
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
type: 'summary',
|
|
132
|
+
title: '此工具痕迹比对工作空间在何时有用',
|
|
133
|
+
items: [
|
|
134
|
+
'教学如何通过比对显微镜将已知测试痕迹置于待检痕迹旁。',
|
|
135
|
+
'使用真实的或训练用的工具痕迹图像准备课堂演示。',
|
|
136
|
+
'解释为什么对齐、比例、旋转、光照和对比度会影响对线条的解读。',
|
|
137
|
+
'创建一个清晰的视觉插图或案卷笔记图像来显示对齐的视图,而不是声称自动识别。',
|
|
138
|
+
'在决定痕迹是否适合进行正式的实验室比对之前筛查图像质量。',
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
type: 'title',
|
|
143
|
+
text: '如何在线比对工具痕迹图像',
|
|
144
|
+
level: 3,
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
type: 'paragraph',
|
|
148
|
+
html: '首先加载一张<strong>已知测试痕迹</strong>图像和一张<strong>待检痕迹</strong>图像。已知痕迹通常是在受控条件下用特定工具产生的。待检痕迹是从现场、物体、门框、锁、切割表面或其他来源未知的物品上提取的痕迹。本工作空间的目的不是自动判定身份,而是将两张图像置于有用的视觉关系中,以便用户检查连续性和差异性。',
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
type: 'paragraph',
|
|
152
|
+
html: '两张图像加载完毕后,使用分屏滑块露出两侧的更多或更少区域。调整水平偏移使线条相位相符,然后旋转待检图像以补偿相机角度或工具角度。缩放有助于比对在不同放大倍率下捕获的两张图像。对比度和亮度有助于显现微弱的划痕或浅显的痕迹,但应记录这些调整,因为它们改变了图像的显示外观。',
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
type: 'comparative',
|
|
156
|
+
columns: 2,
|
|
157
|
+
items: [
|
|
158
|
+
{
|
|
159
|
+
title: '工作区可以帮助您观察的内容',
|
|
160
|
+
icon: 'mdi:check-circle-outline',
|
|
161
|
+
highlight: true,
|
|
162
|
+
description: '良好的分屏设置使您在仔细对齐后更易于检查两个痕迹是否共享可比的视觉结构。',
|
|
163
|
+
points: ['相似的脊线或线条方向', '连续穿过数个相邻特征', '需要校正的比例和旋转差异', '痕迹质量足够强以供检查的区域', '可能提早结束比对的明显种类特征不符'],
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
title: '它不能证明什么',
|
|
167
|
+
icon: 'mdi:alert-circle-outline',
|
|
168
|
+
description: '此浏览器工具不是经过验证的法医鉴定系统,不应用作此类系统。',
|
|
169
|
+
points: ['无自动同源结论', '无随机匹配概率', '无群频估算', '不能替代对原始物证的检验', '不能替代实验室质量保证或同行评审'],
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
type: 'table',
|
|
175
|
+
headers: ['控制项', '改变的内容', '为什么重要'],
|
|
176
|
+
rows: [
|
|
177
|
+
['已知/测试图像上传', '加载对照或参考痕迹。', '已知侧应来自记录在案的测试痕迹或训练示例。'],
|
|
178
|
+
['待检图像上传', '加载来源未知的痕迹。', '移动并调整这一侧以寻找可比的方向。'],
|
|
179
|
+
['分屏手柄', '改变每张图像的可见量。', '可移动的分屏线有助于检查线条是否在边界上视觉上连续。'],
|
|
180
|
+
['水平偏移', '向左或向右移动待检图像。', '细微的侧向移动可使相应的脊线进入或退出相位。'],
|
|
181
|
+
['旋转', '旋转待检图像。', '工具角度、相机角度和固定装置可能会在图像之间产生角度偏差。'],
|
|
182
|
+
['缩放', '改变显示的放大倍率。', '在判断细节之前,必须使不同的图像比例具有可比性。'],
|
|
183
|
+
['对比度和亮度', '调整显示的可见度。', '低对比度的线条可能变得更容易看到,但应记录调整。'],
|
|
184
|
+
['导出视图', '保存当前对齐的 PNG。', '适用于课堂评估、笔记、讨论或明确指出局限性的报告。'],
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
type: 'diagnostic',
|
|
189
|
+
variant: 'warning',
|
|
190
|
+
icon: 'mdi:image-search',
|
|
191
|
+
badge: '图像质量',
|
|
192
|
+
title: '糟糕的图像可能使良好的比对变得不可能',
|
|
193
|
+
html: '工具痕迹比对非常依赖图像质量。模糊、反光、压缩伪影、强阴影、透视变形以及极短的线条区域都可能产生误导性的视觉印象。如果痕迹不能清晰地定向、缩放和照亮,那么适当的结论可能是该图像不适合进行有意义的比对。',
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
type: 'summary',
|
|
197
|
+
title: '比对两个痕迹前的实用清单',
|
|
198
|
+
items: [
|
|
199
|
+
'确认哪张图像是待检痕迹,哪张图像是已知测试痕迹。',
|
|
200
|
+
'检查两张图像是否显示相同的通用痕迹类型、工具接触区域和工作面方向。',
|
|
201
|
+
'避免比对孤立的单根线条;寻找在有用长度上持续存在的相邻细节。',
|
|
202
|
+
'保守使用对比度和亮度,并记录何时应用了图像增强。',
|
|
203
|
+
'仅将对齐的视图导出为所显示内容的图示,而非身份的证明。',
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
type: 'title',
|
|
208
|
+
text: '已知与待检工具痕迹物证',
|
|
209
|
+
level: 3,
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
type: 'paragraph',
|
|
213
|
+
html: '待检工具痕迹通常是从涉及事件的物品上提取的,例如门上的撬痕、电线上的切口、金属上的刮痕或较软表面上的挤压痕。已知痕迹是在受控或有记录的条件下用怀疑的工具产生的。在正式的法医工作中,检验员在花时间研究细微的线条细节之前,会先考虑种类特征是否一致。如果种类特征不符,小区域内的微观相似性不足以支持关联。',
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
type: 'paragraph',
|
|
217
|
+
html: '浏览器工作空间故意保持保守。它帮助您对齐图像并讨论视觉特征,但它不模拟工具磨损、子类特征、基底变形、制造标记、检验员阈值、测量不确定度或误差率。负责任的工作流程将导出的图像视为沟通辅助工具,而非鉴定结果。',
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
type: 'proscons',
|
|
221
|
+
title: '在线工具痕迹图像比对的优势与局限性',
|
|
222
|
+
items: [
|
|
223
|
+
{ pro: '无需安装桌面软件即可加载两张图像并检查对齐的快速方法。', con: '浏览器视图不是经过校准的法医比对显微镜。' },
|
|
224
|
+
{ pro: '有助于教学分屏对比和方向如何影响用户看到的内容。', con: '图像增强可以提高可见度,但也可以改变特征的呈现方式。' },
|
|
225
|
+
{ pro: '导出的 PNG 视图有助于笔记、讨论和视觉说明。', con: '导出的视图无法保存完整的物证背景、元数据或保管链。' },
|
|
226
|
+
{ pro: '适用于用户上传的真实图像,而不仅仅是合成痕迹。', con: '该工具无法确定这两个痕迹是否来自同一工具。' },
|
|
227
|
+
],
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
type: 'glossary',
|
|
231
|
+
items: [
|
|
232
|
+
{ term: '待检痕迹', definition: '从物品或现场提取的、来源未知的痕迹。' },
|
|
233
|
+
{ term: '已知测试痕迹', definition: '用特定工具制成的受控痕迹,用于与待检痕迹进行比对。' },
|
|
234
|
+
{ term: '线条 (Striation)', definition: '工具表面在另一种材料上滑动时产生的微观线或脊。' },
|
|
235
|
+
{ term: '种类特征', definition: '一类工具所共享的特征,如刀刃宽度、大体形状或工具类型。' },
|
|
236
|
+
{ term: '个体特征', definition: '制造、使用、损坏或磨损可能产生的微细表面特征。' },
|
|
237
|
+
{ term: '子类特征', definition: '由于制造工艺而由工具子集共享的特征;它们会使来源解释复杂化。' },
|
|
238
|
+
{ term: '适用性', definition: '关于痕迹是否包含足够质量和数量的细节以支持比对的判定。' },
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
type: 'tip',
|
|
243
|
+
title: '此页面旨在解答的搜索词',
|
|
244
|
+
html: '寻找此类工作空间的用户经常搜索诸如 <strong>工具痕迹对比显微镜在线</strong>、<strong>法医条纹比对</strong>、<strong>已知与待检工具痕迹</strong>、<strong>从图像比对工具痕迹</strong> 以及 <strong>法医对比显微镜模拟器</strong> 等短语。页面围绕这些实际任务构建:上传、对齐、检查、记录和理解局限性。',
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
type: 'table',
|
|
248
|
+
headers: ['常见用户问题', '简短回答'],
|
|
249
|
+
rows: [
|
|
250
|
+
['我可以上传自己的工具痕迹图像吗?', '可以。该工具围绕用户上传的已知和待检图像设计。'],
|
|
251
|
+
['应用程序会判定这两个痕迹是否匹配吗?', '不会。它仅支持视觉对齐,不做出法医同源结论。'],
|
|
252
|
+
['为什么移动的是待检图像而不是两者都移动?', '保持已知侧稳定可以更容易地记录待检图像是如何调整的。'],
|
|
253
|
+
['我可以将其用于枪支或弹壳痕迹吗?', '它可以帮助演示图像对齐概念,但枪支和弹壳比对需要特定学科的步骤和经过验证的审查。'],
|
|
254
|
+
['我可以在笔记中包含导出的 PNG 吗?', '可以,前提是它被明确描述为使用视觉调整控制产生的说明性视图。'],
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
type: 'diagnostic',
|
|
259
|
+
variant: 'warning',
|
|
260
|
+
icon: 'mdi:scale-balance',
|
|
261
|
+
badge: '法医警示',
|
|
262
|
+
title: '描述工具痕迹比对时使用谨慎的语言',
|
|
263
|
+
html: '避免将视觉对齐转化为比证据支持更强烈的结论。合适的语言取决于司法管辖区、实验室政策、验证、图像质量、检验员审查和案件背景。此在线工具应被描述为视觉比对辅助工具,而非鉴定工具的经过验证的方法。',
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
faq,
|
|
267
|
+
bibliography,
|
|
268
|
+
howTo,
|
|
269
|
+
schemas: [
|
|
270
|
+
{
|
|
271
|
+
'@context': 'https://schema.org',
|
|
272
|
+
'@type': 'SoftwareApplication',
|
|
273
|
+
name: title,
|
|
274
|
+
description,
|
|
275
|
+
applicationCategory: 'ForensicApplication',
|
|
276
|
+
operatingSystem: 'Any',
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
'@context': 'https://schema.org',
|
|
280
|
+
'@type': 'FAQPage',
|
|
281
|
+
mainEntity: faq.map((item) => ({
|
|
282
|
+
'@type': 'Question',
|
|
283
|
+
name: item.question,
|
|
284
|
+
acceptedAnswer: {
|
|
285
|
+
'@type': 'Answer',
|
|
286
|
+
text: item.answer,
|
|
287
|
+
},
|
|
288
|
+
})),
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
'@context': 'https://schema.org',
|
|
292
|
+
'@type': 'HowTo',
|
|
293
|
+
name: title,
|
|
294
|
+
step: howTo.map((step) => ({
|
|
295
|
+
'@type': 'HowToStep',
|
|
296
|
+
name: step.name,
|
|
297
|
+
text: step.text,
|
|
298
|
+
})),
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { forensicToolmarkStriationMatcher } from './entry';
|
|
2
|
+
import type { ToolDefinition } from '../../types';
|
|
3
|
+
|
|
4
|
+
export * from './entry';
|
|
5
|
+
|
|
6
|
+
export const FORENSIC_TOOLMARK_STRIATION_MATCHER_TOOL: ToolDefinition = {
|
|
7
|
+
entry: forensicToolmarkStriationMatcher,
|
|
8
|
+
Component: () => import('./component.astro'),
|
|
9
|
+
SEOComponent: () => import('./seo.astro'),
|
|
10
|
+
BibliographyComponent: () => import('./bibliography.astro'),
|
|
11
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export type ToolProfile = 'screwdriver' | 'prybar' | 'boltCutter';
|
|
2
|
+
|
|
3
|
+
export interface ToolmarkInput {
|
|
4
|
+
profile: ToolProfile;
|
|
5
|
+
offsetMicrons: number;
|
|
6
|
+
rotationDegrees: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ToolmarkResult {
|
|
10
|
+
offsetMicrons: number;
|
|
11
|
+
offsetThousandths: number;
|
|
12
|
+
rotationDegrees: number;
|
|
13
|
+
correlation: number;
|
|
14
|
+
phaseScore: number;
|
|
15
|
+
rotationScore: number;
|
|
16
|
+
relief: number[];
|
|
17
|
+
interpretationKey: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const PROFILE_SEEDS: Record<ToolProfile, number[]> = {
|
|
21
|
+
screwdriver: [0.12, 0.4, 0.78, 0.22, 0.6, 0.91, 0.34, 0.55, 0.18, 0.73, 0.48, 0.86],
|
|
22
|
+
prybar: [0.65, 0.24, 0.88, 0.42, 0.16, 0.72, 0.31, 0.94, 0.52, 0.2, 0.8, 0.38],
|
|
23
|
+
boltCutter: [0.26, 0.84, 0.44, 0.7, 0.18, 0.58, 0.96, 0.36, 0.76, 0.12, 0.5, 0.9],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function clamp(value: number, min: number, max: number): number {
|
|
27
|
+
return Math.max(min, Math.min(max, value));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function gaussian(value: number, sigma: number): number {
|
|
31
|
+
return Math.exp(-(value * value) / (2 * sigma * sigma));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function interpretationKey(correlation: number): string {
|
|
35
|
+
if (correlation >= 82) return 'interpMatch';
|
|
36
|
+
if (correlation >= 58) return 'interpPossible';
|
|
37
|
+
return 'interpMismatch';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class StriationMatcher {
|
|
41
|
+
buildRelief(profile: ToolProfile, offsetMicrons: number, rotationDegrees: number): number[] {
|
|
42
|
+
const seed = PROFILE_SEEDS[profile] ?? PROFILE_SEEDS.screwdriver;
|
|
43
|
+
const relief: number[] = [];
|
|
44
|
+
for (let i = 0; i < 48; i += 1) {
|
|
45
|
+
const base = seed[i % seed.length];
|
|
46
|
+
const wave = Math.sin((i * 0.72) + (offsetMicrons / 18)) * 0.18;
|
|
47
|
+
const tilt = Math.sin((rotationDegrees * Math.PI / 180) + (i * 0.17)) * 0.08;
|
|
48
|
+
relief.push(Number(clamp(base + wave + tilt, 0.04, 1).toFixed(3)));
|
|
49
|
+
}
|
|
50
|
+
return relief;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
compare(input: ToolmarkInput): ToolmarkResult {
|
|
54
|
+
const offsetMicrons = clamp(input.offsetMicrons, -180, 180);
|
|
55
|
+
const rotationDegrees = clamp(input.rotationDegrees, -12, 12);
|
|
56
|
+
const phaseScore = gaussian(offsetMicrons, 46);
|
|
57
|
+
const rotationScore = gaussian(rotationDegrees, 3.2);
|
|
58
|
+
const correlation = Math.round(clamp((phaseScore * 0.62 + rotationScore * 0.38) * 100, 0, 100));
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
offsetMicrons: Number(offsetMicrons.toFixed(1)),
|
|
62
|
+
offsetThousandths: Number((offsetMicrons / 25.4).toFixed(2)),
|
|
63
|
+
rotationDegrees: Number(rotationDegrees.toFixed(1)),
|
|
64
|
+
correlation,
|
|
65
|
+
phaseScore: Math.round(phaseScore * 100),
|
|
66
|
+
rotationScore: Math.round(rotationScore * 100),
|
|
67
|
+
relief: this.buildRelief(input.profile, offsetMicrons, rotationDegrees),
|
|
68
|
+
interpretationKey: interpretationKey(correlation),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { ToolmarkResult } from './logic';
|
|
2
|
+
|
|
3
|
+
export type UploadedSample = { image: HTMLImageElement; name: string };
|
|
4
|
+
|
|
5
|
+
export interface RenderState {
|
|
6
|
+
result: ToolmarkResult;
|
|
7
|
+
knownSample: UploadedSample | null;
|
|
8
|
+
questionedSample: UploadedSample | null;
|
|
9
|
+
splitPercent: number;
|
|
10
|
+
zoomPercent: number;
|
|
11
|
+
contrastPercent: number;
|
|
12
|
+
brightnessPercent: number;
|
|
13
|
+
gridEnabled: boolean;
|
|
14
|
+
dark: boolean;
|
|
15
|
+
reliefGraphLabel: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Size {
|
|
19
|
+
width: number;
|
|
20
|
+
height: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface PanelConfig extends Size {
|
|
24
|
+
x: number;
|
|
25
|
+
known: boolean;
|
|
26
|
+
sample: UploadedSample | null;
|
|
27
|
+
state: RenderState;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Rect {
|
|
31
|
+
x: number;
|
|
32
|
+
y: number;
|
|
33
|
+
width: number;
|
|
34
|
+
height: number;
|
|
35
|
+
radius: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface DemoLineConfig {
|
|
39
|
+
result: ToolmarkResult;
|
|
40
|
+
index: number;
|
|
41
|
+
lateral: number;
|
|
42
|
+
dark: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const PANEL_COLORS: Record<string, string> = {
|
|
46
|
+
knownDarkStart: '#17252d',
|
|
47
|
+
knownDarkEnd: '#0d1a21',
|
|
48
|
+
knownLightStart: '#d7e5e0',
|
|
49
|
+
knownLightEnd: '#eef5f1',
|
|
50
|
+
questionedDarkStart: '#241820',
|
|
51
|
+
questionedDarkEnd: '#1a141c',
|
|
52
|
+
questionedLightStart: '#eadad5',
|
|
53
|
+
questionedLightEnd: '#f5ebe8',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function panelColor(known: boolean, dark: boolean, end = false): string {
|
|
57
|
+
const subject = known ? 'known' : 'questioned';
|
|
58
|
+
const theme = dark ? 'Dark' : 'Light';
|
|
59
|
+
const side = end ? 'End' : 'Start';
|
|
60
|
+
return PANEL_COLORS[`${subject}${theme}${side}`];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function roundRect(ctx: CanvasRenderingContext2D, rect: Rect): void {
|
|
64
|
+
ctx.beginPath();
|
|
65
|
+
ctx.moveTo(rect.x + rect.radius, rect.y);
|
|
66
|
+
ctx.arcTo(rect.x + rect.width, rect.y, rect.x + rect.width, rect.y + rect.height, rect.radius);
|
|
67
|
+
ctx.arcTo(rect.x + rect.width, rect.y + rect.height, rect.x, rect.y + rect.height, rect.radius);
|
|
68
|
+
ctx.arcTo(rect.x, rect.y + rect.height, rect.x, rect.y, rect.radius);
|
|
69
|
+
ctx.arcTo(rect.x, rect.y, rect.x + rect.width, rect.y, rect.radius);
|
|
70
|
+
ctx.closePath();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function drawBench(ctx: CanvasRenderingContext2D, size: Size, dark: boolean): void {
|
|
74
|
+
const gradient = ctx.createLinearGradient(0, 0, size.width, size.height);
|
|
75
|
+
gradient.addColorStop(0, dark ? '#0c141a' : '#dfe9e6');
|
|
76
|
+
gradient.addColorStop(0.48, dark ? '#13212a' : '#edf2ee');
|
|
77
|
+
gradient.addColorStop(1, dark ? '#19141b' : '#eaded9');
|
|
78
|
+
ctx.fillStyle = gradient;
|
|
79
|
+
ctx.fillRect(0, 0, size.width, size.height);
|
|
80
|
+
drawGrain(ctx, size, dark);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function drawGrain(ctx: CanvasRenderingContext2D, size: Size, dark: boolean): void {
|
|
84
|
+
for (let i = 0; i < 120; i += 1) {
|
|
85
|
+
ctx.fillStyle = dark ? 'rgba(255,255,255,0.035)' : 'rgba(15,23,42,0.035)';
|
|
86
|
+
ctx.fillRect((i * 79) % size.width, (i * 43) % size.height, 1.2, 1.2);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function drawPanel(ctx: CanvasRenderingContext2D, config: PanelConfig): void {
|
|
91
|
+
ctx.save();
|
|
92
|
+
ctx.beginPath();
|
|
93
|
+
ctx.rect(config.x, 0, config.width, config.height);
|
|
94
|
+
ctx.clip();
|
|
95
|
+
fillPanel(ctx, config);
|
|
96
|
+
drawMetalSheen(ctx, config, config.state.dark);
|
|
97
|
+
if (config.sample) drawUploadedSample(ctx, config);
|
|
98
|
+
else drawDemoStriations(ctx, config);
|
|
99
|
+
ctx.restore();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function fillPanel(ctx: CanvasRenderingContext2D, config: PanelConfig): void {
|
|
103
|
+
const panel = ctx.createLinearGradient(config.x, 0, config.x + config.width, config.height);
|
|
104
|
+
panel.addColorStop(0, panelColor(config.known, config.state.dark));
|
|
105
|
+
panel.addColorStop(1, panelColor(config.known, config.state.dark, true));
|
|
106
|
+
ctx.fillStyle = panel;
|
|
107
|
+
ctx.fillRect(config.x, 0, config.width, config.height);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function drawMetalSheen(ctx: CanvasRenderingContext2D, config: PanelConfig, dark: boolean): void {
|
|
111
|
+
for (let i = 0; i < 9; i += 1) {
|
|
112
|
+
const y = 54 + i * 48;
|
|
113
|
+
ctx.strokeStyle = dark ? 'rgba(255,255,255,0.035)' : 'rgba(255,255,255,0.34)';
|
|
114
|
+
ctx.lineWidth = 1;
|
|
115
|
+
ctx.beginPath();
|
|
116
|
+
ctx.moveTo(config.x + 22, y);
|
|
117
|
+
ctx.lineTo(config.x + config.width - 22, y + 18);
|
|
118
|
+
ctx.stroke();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function drawDemoStriations(ctx: CanvasRenderingContext2D, config: PanelConfig): void {
|
|
123
|
+
const result = config.state.result;
|
|
124
|
+
ctx.translate(config.x + config.width / 2, config.height * 0.44);
|
|
125
|
+
ctx.rotate((config.known ? 0 : result.rotationDegrees) * Math.PI / 180);
|
|
126
|
+
const lateral = config.known ? 0 : result.offsetMicrons * 0.55;
|
|
127
|
+
for (let i = -22; i <= 22; i += 1) drawDemoLine(ctx, { result, index: i, lateral, dark: config.state.dark });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function drawDemoLine(ctx: CanvasRenderingContext2D, config: DemoLineConfig): void {
|
|
131
|
+
const seed = config.result.relief[(config.index + 48) % config.result.relief.length];
|
|
132
|
+
const px = (config.index * 18) + config.lateral;
|
|
133
|
+
const depth = 72 + seed * 132;
|
|
134
|
+
ctx.shadowColor = config.dark ? 'rgba(20,184,166,0.2)' : 'rgba(20,184,166,0.14)';
|
|
135
|
+
ctx.shadowBlur = seed > 0.72 ? 10 : 0;
|
|
136
|
+
ctx.strokeStyle = config.dark ? `rgba(226,232,240,${0.22 + seed * 0.52})` : `rgba(31,43,50,${0.24 + seed * 0.52})`;
|
|
137
|
+
ctx.lineWidth = 1.8 + seed * 4.8;
|
|
138
|
+
ctx.beginPath();
|
|
139
|
+
ctx.moveTo(px, -depth);
|
|
140
|
+
ctx.bezierCurveTo(px - 10 - seed * 8, -24, px + 12 + seed * 6, 32, px - 4, depth);
|
|
141
|
+
ctx.stroke();
|
|
142
|
+
ctx.shadowBlur = 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function drawUploadedSample(ctx: CanvasRenderingContext2D, config: PanelConfig): void {
|
|
146
|
+
if (!config.sample) return;
|
|
147
|
+
const image = config.sample.image;
|
|
148
|
+
const baseScale = Math.max(config.width / image.width, config.height / image.height);
|
|
149
|
+
const scale = baseScale * (config.state.zoomPercent / 100);
|
|
150
|
+
const result = config.state.result;
|
|
151
|
+
const dx = config.known ? 0 : result.offsetMicrons * 0.72;
|
|
152
|
+
const dy = config.known ? 0 : result.offsetMicrons * 0.08;
|
|
153
|
+
ctx.save();
|
|
154
|
+
ctx.translate(config.x + config.width / 2 + dx, config.height / 2 + dy);
|
|
155
|
+
ctx.rotate((config.known ? 0 : result.rotationDegrees) * Math.PI / 180);
|
|
156
|
+
ctx.filter = `grayscale(100%) contrast(${config.state.contrastPercent}%) brightness(${config.state.brightnessPercent}%)`;
|
|
157
|
+
ctx.drawImage(image, image.width * scale / -2, image.height * scale / -2, image.width * scale, image.height * scale);
|
|
158
|
+
ctx.filter = 'none';
|
|
159
|
+
ctx.globalCompositeOperation = 'overlay';
|
|
160
|
+
ctx.fillStyle = 'rgba(20,184,166,0.12)';
|
|
161
|
+
ctx.fillRect(-config.width, -config.height, config.width * 2, config.height * 2);
|
|
162
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
163
|
+
ctx.restore();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function drawCorrespondenceBands(ctx: CanvasRenderingContext2D, state: RenderState, split: number, size: Size): void {
|
|
167
|
+
if (state.result.correlation < 58) return;
|
|
168
|
+
const alpha = state.result.correlation >= 82 ? 0.2 : 0.1;
|
|
169
|
+
ctx.fillStyle = state.dark ? `rgba(20,184,166,${alpha})` : `rgba(13,148,136,${alpha})`;
|
|
170
|
+
for (let i = 0; i < 5; i += 1) {
|
|
171
|
+
const y = 118 + i * 58;
|
|
172
|
+
ctx.fillRect(Math.max(28, split - 240), y, Math.min(480, size.width - 56), 18);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function drawDivider(ctx: CanvasRenderingContext2D, split: number, height: number): void {
|
|
177
|
+
ctx.shadowColor = 'rgba(245,158,11,0.65)';
|
|
178
|
+
ctx.shadowBlur = 18;
|
|
179
|
+
ctx.fillStyle = '#f59e0b';
|
|
180
|
+
ctx.fillRect(split - 2, 0, 4, height);
|
|
181
|
+
ctx.shadowBlur = 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function drawRelief(ctx: CanvasRenderingContext2D, state: RenderState, size: Size): void {
|
|
185
|
+
const graphTop = size.height - 132;
|
|
186
|
+
ctx.fillStyle = state.dark ? 'rgba(4,10,14,0.72)' : 'rgba(255,255,255,0.74)';
|
|
187
|
+
roundRect(ctx, { x: 30, y: graphTop, width: size.width - 60, height: 92, radius: 16 });
|
|
188
|
+
ctx.fill();
|
|
189
|
+
drawReliefLine(ctx, state, graphTop, size.width);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function drawReliefLine(ctx: CanvasRenderingContext2D, state: RenderState, graphTop: number, width: number): void {
|
|
193
|
+
ctx.beginPath();
|
|
194
|
+
state.result.relief.forEach((value, index) => {
|
|
195
|
+
const x = 42 + (index / (state.result.relief.length - 1)) * (width - 84);
|
|
196
|
+
const y = graphTop + 74 - value * 62;
|
|
197
|
+
if (index === 0) ctx.moveTo(x, y);
|
|
198
|
+
else ctx.lineTo(x, y);
|
|
199
|
+
});
|
|
200
|
+
ctx.lineWidth = 4.5;
|
|
201
|
+
ctx.strokeStyle = '#14b8a6';
|
|
202
|
+
ctx.stroke();
|
|
203
|
+
ctx.fillStyle = state.dark ? '#e2e8f0' : '#1f2933';
|
|
204
|
+
ctx.font = '800 18px system-ui, sans-serif';
|
|
205
|
+
ctx.fillText(state.reliefGraphLabel, 44, graphTop + 28);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function drawReticle(ctx: CanvasRenderingContext2D, size: Size, dark: boolean): void {
|
|
209
|
+
ctx.strokeStyle = dark ? 'rgba(255,255,255,0.12)' : 'rgba(15,23,42,0.12)';
|
|
210
|
+
ctx.lineWidth = 1;
|
|
211
|
+
ctx.beginPath();
|
|
212
|
+
ctx.ellipse(size.width / 2, size.height * 0.43, size.width * 0.47, size.height * 0.37, 0, 0, Math.PI * 2);
|
|
213
|
+
ctx.stroke();
|
|
214
|
+
ctx.beginPath();
|
|
215
|
+
ctx.moveTo(26, size.height * 0.43);
|
|
216
|
+
ctx.lineTo(size.width - 26, size.height * 0.43);
|
|
217
|
+
ctx.moveTo(size.width / 2, 28);
|
|
218
|
+
ctx.lineTo(size.width / 2, size.height - 28);
|
|
219
|
+
ctx.stroke();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function renderToolmarkCanvas(canvas: HTMLCanvasElement, state: RenderState): void {
|
|
223
|
+
const ctx = canvas.getContext('2d');
|
|
224
|
+
if (!ctx) return;
|
|
225
|
+
const size = { width: canvas.width, height: canvas.height };
|
|
226
|
+
const split = size.width * (state.splitPercent / 100);
|
|
227
|
+
ctx.clearRect(0, 0, size.width, size.height);
|
|
228
|
+
drawBench(ctx, size, state.dark);
|
|
229
|
+
drawPanel(ctx, { x: 0, width: split, height: size.height, known: true, sample: state.knownSample, state });
|
|
230
|
+
drawPanel(ctx, { x: split, width: size.width - split, height: size.height, known: false, sample: state.questionedSample, state });
|
|
231
|
+
if (!state.knownSample && !state.questionedSample) {
|
|
232
|
+
drawCorrespondenceBands(ctx, state, split, size);
|
|
233
|
+
drawRelief(ctx, state, size);
|
|
234
|
+
}
|
|
235
|
+
drawDivider(ctx, split, size.height);
|
|
236
|
+
if (state.gridEnabled) drawReticle(ctx, size, state.dark);
|
|
237
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { forensicToolmarkStriationMatcher } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en' } = Astro.props;
|
|
11
|
+
const content = await forensicToolmarkStriationMatcher.i18n[locale]?.();
|
|
12
|
+
if (!content) return null;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{content.seo && content.seo.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|