File size: 26,735 Bytes
065e391
 
 
 
 
 
 
 
 
 
 
 
 
 
4caef13
 
065e391
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4caef13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
065e391
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4caef13
 
 
 
 
 
 
 
 
 
 
 
 
 
065e391
 
 
 
 
 
 
 
 
 
 
 
4caef13
 
065e391
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
from pathlib import Path
import json
import ncempy.io as nio
import tifffile
import cv2
import numpy as np
import re
import h5py
import xml.etree.ElementTree as ET

def _convert_to_nm(value, unit):
    """Convert a pixel size value from given unit to nanometers."""
    unit = unit.lower().strip()
    conversions = {
        'pm': 1e-3, 'picometer': 1e-3, 'picometers': 1e-3,
        'a': 0.1, 'å': 0.1, 'angstrom': 0.1, 'angstroms': 0.1, 'ang': 0.1,
        'nm': 1, 'nanometer': 1, 'nanometers': 1,
        'um': 1e3, 'µm': 1e3, 'micrometer': 1e3, 'micrometers': 1e3, 'micron': 1e3, 'microns': 1e3,
        'mm': 1e6, 'millimeter': 1e6, 'millimeters': 1e6,
        'cm': 1e7, 'centimeter': 1e7, 'centimeters': 1e7,
        'm': 1e9, 'meter': 1e9, 'meters': 1e9,
        'inch': 2.54e7, 'inches': 2.54e7,
    }
    factor = conversions.get(unit)
    if factor is not None:
        return value * factor
    return None


def _decode_tiff_tag_texts(value):
    """Yield searchable text variants from TIFF tag values, including raw bytes."""
    if value is None:
        return []

    texts = []
    if isinstance(value, bytes):
        for encoding in ("latin1", "utf-8", "utf-16le"):
            try:
                decoded = value.decode(encoding, errors="ignore")
            except Exception:
                continue
            if decoded:
                texts.append(decoded)
    else:
        try:
            text = str(value)
        except Exception:
            text = ""
        if text:
            texts.append(text)

    variants = []
    seen = set()
    for text in texts:
        for candidate in (text, text.replace("\x00", "")):
            if candidate and candidate not in seen:
                seen.add(candidate)
                variants.append(candidate)
    return variants


def _normalize_h5_attr(value):
    if isinstance(value, bytes):
        return value.decode("utf-8", errors="ignore")
    return value


def _coerce_float(value):
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


def _emd_dataset_to_2d(raw):
    """Reduce common EMD dataset layouts to a 2D image plane."""
    arr = np.asarray(raw)
    if arr.ndim < 2:
        return None

    arr = np.squeeze(arr)
    if arr.ndim < 2:
        return None

    while arr.ndim > 2:
        # Prefer collapsing the smallest axis first:
        # (y, x, 1) -> squeeze to (y, x)
        # (frames, y, x) -> take first frame
        # (y, x, channels) -> take first channel
        axis = int(np.argmin(arr.shape))
        arr = np.take(arr, indices=0, axis=axis)

    return arr


def _normalize_to_uint8(raw):
    arr = np.asarray(raw)
    if arr.size == 0:
        return None
    norm = cv2.normalize(arr, None, 0, 255, cv2.NORM_MINMAX)
    return norm.astype(np.uint8)


def _iter_emd_image_groups(h5_file):
    """Yield candidate EMD image groups in Velox and Berkeley-style layouts."""
    if 'Data' in h5_file and 'Image' in h5_file['Data']:
        for key, grp in h5_file['Data']['Image'].items():
            if isinstance(grp, h5py.Group) and 'Data' in grp and isinstance(grp['Data'], h5py.Dataset):
                yield "velox", key, grp, 'Data'

    if 'data' in h5_file:
        for key, grp in h5_file['data'].items():
            if isinstance(grp, h5py.Group) and 'data' in grp and isinstance(grp['data'], h5py.Dataset):
                yield "berkeley", key, grp, 'data'


def _read_emd_metadata_text(group):
    dataset = group.get('Metadata')
    if not isinstance(dataset, h5py.Dataset):
        return None

    try:
        raw = bytes(np.asarray(dataset).reshape(-1).tolist())
    except Exception:
        return None

    text = raw.decode("utf-8", errors="ignore").strip("\x00 \n\r\t")
    return text or None


def _extract_emd_pixel_size_from_metadata_text(metadata_text):
    if not metadata_text:
        return None

    try:
        payload = json.loads(metadata_text)
    except json.JSONDecodeError:
        payload = None

    if payload is not None:
        stack = [payload]
        while stack:
            node = stack.pop()
            if isinstance(node, dict):
                pixel_size = node.get("PixelSize")
                if isinstance(pixel_size, dict):
                    for axis_key, unit_key in (("width", "PixelUnitX"), ("height", "PixelUnitY"), ("x", "PixelUnitX"), ("y", "PixelUnitY")):
                        value = _coerce_float(pixel_size.get(axis_key))
                        unit = node.get(unit_key) or node.get("PixelUnit") or pixel_size.get("unit")
                        if value is not None and unit:
                            nm = _convert_to_nm(value, str(unit))
                            if nm and nm > 0:
                                return nm
                stack.extend(node.values())
            elif isinstance(node, list):
                stack.extend(node)

    match = re.search(
        r'"PixelSize"\s*:\s*\{[^{}]*"width"\s*:\s*"?(?P<value>[\d.eE+-]+)"?[^{}]*\}[^{}]*"PixelUnitX"\s*:\s*"(?P<unit>[^"]+)"',
        metadata_text,
        re.IGNORECASE | re.DOTALL,
    )
    if match:
        value = _coerce_float(match.group("value"))
        unit = match.group("unit")
        if value is not None:
            nm = _convert_to_nm(value, unit)
            if nm and nm > 0:
                return nm

    return None


def read_emd_pixel_size(image_path: Path):
    """Read pixel size from common EMD metadata layouts and Velox JSON metadata."""
    try:
        with h5py.File(str(image_path), 'r') as f:
            for fmt, key, grp, _ in _iter_emd_image_groups(f):
                if fmt == "velox":
                    pixel_size_group = grp.get('PixelSize')
                    if isinstance(pixel_size_group, h5py.Group):
                        width = _coerce_float(_normalize_h5_attr(pixel_size_group.attrs.get('width')))
                        unit = _normalize_h5_attr(pixel_size_group.attrs.get('unit', 'm'))
                        if width is not None:
                            nm = _convert_to_nm(width, str(unit))
                            if nm and nm > 0:
                                return nm

                    dimension_group = grp.get('Dimension')
                    if isinstance(dimension_group, h5py.Group):
                        for dim in dimension_group.values():
                            if not isinstance(dim, h5py.Dataset):
                                continue
                            scale = _coerce_float(_normalize_h5_attr(dim.attrs.get('Scale')))
                            unit = _normalize_h5_attr(dim.attrs.get('Unit', ''))
                            if scale is None or not unit:
                                continue
                            nm = _convert_to_nm(scale, str(unit))
                            if nm and nm > 0:
                                return nm

                    metadata_text = _read_emd_metadata_text(grp)
                    metadata_nm = _extract_emd_pixel_size_from_metadata_text(metadata_text)
                    if metadata_nm:
                        return metadata_nm

                if fmt == "berkeley":
                    dim1 = grp.get('dim1')
                    if isinstance(dim1, h5py.Dataset):
                        dim1_data = dim1[:]
                        if len(dim1_data) > 1:
                            delta = _coerce_float(dim1_data[1] - dim1_data[0])
                            if delta is not None and delta > 0:
                                return delta
    except Exception as e:
        print(f"Error reading EMD metadata: {e}")

    return None


def read_emd_image(image_path: Path):
    """Read and normalize the best available 2D image plane from an EMD file."""
    try:
        with h5py.File(str(image_path), 'r') as f:
            best_plane = None
            best_area = -1

            for _, _, grp, data_key in _iter_emd_image_groups(f):
                raw = grp[data_key][:]
                plane = _emd_dataset_to_2d(raw)
                if plane is None:
                    continue

                area = int(plane.shape[0]) * int(plane.shape[1])
                if area > best_area:
                    best_plane = plane
                    best_area = area

            if best_plane is not None:
                return _normalize_to_uint8(best_plane)
    except Exception as e:
        print(f"Error reading EMD image: {e}")

    return None


def _read_tiff_pixel_size(image_path):
    """
    Try all known methods to extract pixel size from a TIFF file.
    Returns (pixel_size_nm, method_string) or (None, None).
    """
    with tifffile.TiffFile(str(image_path)) as tif:

        # --- Method 1: ImageJ metadata ---
        meta = getattr(tif, 'imagej_metadata', None)
        if meta:
            # 1a. Direct 'spacing' + 'unit'
            if 'unit' in meta and 'spacing' in meta:
                spacing = float(meta['spacing'])
                nm = _convert_to_nm(spacing, meta['unit'])
                if nm is not None and nm > 0:
                    print(f"[TIFF scale] ImageJ spacing={spacing} {meta['unit']} → {nm} nm/px")
                    return nm, "metadata_imagej"

            # 1b. ImageJ 'info' string (often contains scale info as key=value pairs)
            info_str = meta.get('Info') or meta.get('info') or ''
            if info_str:
                # Try "Pixel Width = 0.245 nm" or "Scale = 0.5 um" patterns
                for pattern in [
                    r'pixel\s*(?:width|size)\s*[=:]\s*([\d.eE+-]+)\s*(nm|µm|um|mm|m)\b',
                    r'scale\s*[=:]\s*([\d.eE+-]+)\s*(nm|µm|um|mm|m)\b',
                ]:
                    m = re.search(pattern, info_str, re.IGNORECASE)
                    if m:
                        val = float(m.group(1))
                        nm = _convert_to_nm(val, m.group(2))
                        if nm and nm > 0:
                            print(f"[TIFF scale] ImageJ info field: {m.group(0)} → {nm} nm/px")
                            return nm, "metadata_imagej_info"

        # --- Method 2: OME-TIFF (XML in ImageDescription) ---
        if tif.pages:
            desc_tag = tif.pages[0].tags.get('ImageDescription')
            if desc_tag:
                desc = str(desc_tag.value)
                if desc.strip().startswith('<?xml') or '<OME' in desc:
                    try:
                        # Strip XML namespace for simpler parsing
                        desc_clean = re.sub(r'\sxmlns="[^"]+"', '', desc, count=1)
                        root = ET.fromstring(desc_clean)
                        # Look for <Pixels PhysicalSizeX="..." PhysicalSizeXUnit="...">
                        for elem in root.iter():
                            if 'Pixels' in elem.tag:
                                ps_x = elem.attrib.get('PhysicalSizeX')
                                ps_unit = elem.attrib.get('PhysicalSizeXUnit', 'µm')  # OME default is µm
                                if ps_x:
                                    val = float(ps_x)
                                    nm = _convert_to_nm(val, ps_unit)
                                    if nm and nm > 0:
                                        print(f"[TIFF scale] OME-TIFF PhysicalSizeX={ps_x} {ps_unit} → {nm} nm/px")
                                        return nm, "metadata_ome"
                    except ET.ParseError:
                        pass

        # --- Method 3: FEI / Thermo Fisher / DigitalMicrograph custom TIFF tags ---
        if tif.pages:
            page = tif.pages[0]
            for tag_id in (34682, 65027):
                fei_tag = page.tags.get(tag_id)
                if not fei_tag:
                    continue

                for fei_meta in _decode_tiff_tag_texts(fei_tag.value):
                    # Legacy FEI metadata stores PixelWidth in meters.
                    m = re.search(r'PixelWidth\s*=\s*([\d.eE+-]+)', fei_meta)
                    if m:
                        val_m = float(m.group(1))
                        nm = val_m * 1e9
                        if nm > 0:
                            print(f"[TIFF scale] FEI tag {tag_id} PixelWidth={val_m} m → {nm} nm/px")
                            return nm, f"metadata_fei_{tag_id}"

                    # DigitalMicrograph/JEOL headers often store PixelSizeX/Y as UTF-16-ish bytes.
                    for pattern in [
                        r'PixelSize(?:[XY])?[^0-9-]{0,80}(-?[\d.]+(?:e[-+]?\d+)?)\s*(nm|µm|um|mm|m)\b',
                        r'Pixel(?:Width|Height)(?:[XY])?[^0-9-]{0,80}(-?[\d.]+(?:e[-+]?\d+)?)\s*(nm|µm|um|mm|m)\b',
                    ]:
                        m = re.search(pattern, fei_meta, re.IGNORECASE | re.DOTALL)
                        if m:
                            val = float(m.group(1))
                            nm = _convert_to_nm(val, m.group(2))
                            if nm and nm > 0:
                                print(f"[TIFF scale] Private tag {tag_id}: {m.group(0)} → {nm} nm/px")
                                return nm, f"metadata_private_{tag_id}"

        # --- Method 4: ImageDescription free-text patterns ---
        if tif.pages:
            desc_tag = tif.pages[0].tags.get('ImageDescription')
            if desc_tag:
                desc = str(desc_tag.value)
                for pattern in [
                    r'pixel\s*size\s*[=:]\s*([\d.eE+-]+)\s*(nm|µm|um|mm|m)\b',
                    r'scale\s*[=:]\s*([\d.eE+-]+)\s*(nm|µm|um|mm|m)\b',
                    r'resolution\s*[=:]\s*([\d.eE+-]+)\s*(nm|µm|um|mm|m)\s*/\s*(?:pixel|px)',
                ]:
                    m = re.search(pattern, desc, re.IGNORECASE)
                    if m:
                        val = float(m.group(1))
                        nm = _convert_to_nm(val, m.group(2))
                        if nm and nm > 0:
                            print(f"[TIFF scale] ImageDescription: {m.group(0)} → {nm} nm/px")
                            return nm, "metadata_tiff_description"

        # --- Method 5: Standard TIFF resolution tags ---
        # CAUTION: most TIFFs have default 72 or 96 dpi, which is meaningless for microscopy.
        # Only use this if the result gives a plausible nm/pixel for TEM/SEM (0.01 ~ 100 nm/px).
        if tif.pages:
            page = tif.pages[0]
            tags = page.tags
            x_res_tag = tags.get('XResolution')
            res_unit_tag = tags.get('ResolutionUnit')
            if x_res_tag is not None:
                x_res_val = x_res_tag.value
                pixels_per_unit = None
                if isinstance(x_res_val, tuple) and len(x_res_val) == 2 and x_res_val[0] > 0:
                    pixels_per_unit = x_res_val[0] / x_res_val[1]
                elif isinstance(x_res_val, (int, float)) and x_res_val > 0:
                    pixels_per_unit = float(x_res_val)

                if pixels_per_unit and pixels_per_unit > 0:
                    res_unit = res_unit_tag.value if res_unit_tag else 1
                    nm_per_px = None
                    if res_unit == 3:  # centimeter
                        nm_per_px = 1e7 / pixels_per_unit
                    elif res_unit == 2:  # inch
                        nm_per_px = 2.54e7 / pixels_per_unit

                    # Only trust if result is in plausible microscopy range
                    if nm_per_px is not None and 0.01 <= nm_per_px <= 1000:
                        print(f"[TIFF scale] Resolution tag: {pixels_per_unit} px/unit (unit={res_unit}) → {nm_per_px} nm/px")
                        return nm_per_px, "metadata_tiff_resolution"
                    elif nm_per_px is not None:
                        print(f"[TIFF scale] Resolution tag gives {nm_per_px} nm/px — outside plausible range, ignoring (likely default DPI)")

        # --- Method 6: Check all TIFF tags for anything scale-related ---
        if tif.pages:
            for tag in tif.pages[0].tags.values():
                for tag_str in _decode_tiff_tag_texts(tag.value):
                    if len(tag_str) > 20000:
                        continue  # skip huge binary blobs
                    for pattern in [
                        r'(?:pixel|image)\s*(?:width|size|scale|resolution)\s*[=:]\s*([\d.eE+-]+)\s*(nm|µm|um|mm|m)\b',
                        r'(?:pixel|image)\s*(?:width|size|scale|resolution)[^0-9-]{0,80}(-?[\d.]+(?:e[-+]?\d+)?)\s*(nm|µm|um|mm|m)\b',
                    ]:
                        m = re.search(pattern, tag_str, re.IGNORECASE | re.DOTALL)
                        if m:
                            val = float(m.group(1))
                            nm = _convert_to_nm(val, m.group(2))
                            if nm and nm > 0:
                                print(f"[TIFF scale] Tag {tag.name}: {m.group(0)} → {nm} nm/px")
                                return nm, f"metadata_tiff_tag_{tag.name}"

    return None, None


# --- Burned-in scale detection (OCR) ----------------------------------------
# Many camera exports (e.g. Velox/Ceta JPG/PNG) carry no embedded calibration
# but print it into the image: a metadata footer ("Pixel size 156.1 pm",
# "Fov 639.3 nm") and/or a drawn scale bar with a "200 nm" label. We read those
# with Tesseract OCR. Everything degrades silently when Tesseract/pytesseract is
# not installed, so the app keeps working (falling back to manual calibration).

_PLAUSIBLE_NM_PER_PX = (0.001, 1000.0)  # sane TEM/SEM range


def _tesseract_ready():
    """Return True if pytesseract + the Tesseract binary are usable; point
    pytesseract at common install locations (incl. Windows) when needed."""
    import os
    import shutil
    try:
        import pytesseract
    except Exception:
        return False

    if shutil.which("tesseract"):
        return True

    for candidate in (
        r"C:\Program Files\Tesseract-OCR\tesseract.exe",
        r"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe",
        "/opt/homebrew/bin/tesseract",
        "/usr/local/bin/tesseract",
        "/usr/bin/tesseract",
    ):
        if os.path.exists(candidate):
            pytesseract.pytesseract.tesseract_cmd = candidate
            return True
    return False


def _ocr_text(gray, psm=6):
    """OCR a grayscale crop, upscaling small crops so digits are legible."""
    import pytesseract
    if gray is None or gray.size == 0:
        return ""
    h, w = gray.shape[:2]
    if max(h, w) < 2500:
        gray = cv2.resize(gray, (w * 2, h * 2), interpolation=cv2.INTER_CUBIC)
    try:
        return pytesseract.image_to_string(gray, config=f"--psm {psm}")
    except Exception as e:
        print(f"[Scale] OCR error: {e}")
        return ""


def _plausible(nm_per_px):
    lo, hi = _PLAUSIBLE_NM_PER_PX
    return nm_per_px is not None and lo <= nm_per_px <= hi


def _parse_footer_calibration(text, image_width):
    """
    Parse a metadata footer into nm/pixel. Two independent estimates:
      A = an explicit pixel-size token in pm  (e.g. "156.1 pm")
      B = field-of-view length / image width  (e.g. "639.3 nm" / 4096)
    Prefer A when present; use B otherwise. When both exist they cross-check
    each other, which guards against OCR misreads.
    """
    norm = " ".join(text.split())
    tokens = re.findall(r'([\d]+\.?[\d]*)\s*(pm|nm|µm|um|microns?)\b', norm, re.IGNORECASE)
    pm_vals, len_nm_vals = [], []
    for value, unit in tokens:
        u = unit.lower()
        nm = _convert_to_nm(float(value), u)
        if nm is None:
            continue
        if u == 'pm':
            pm_vals.append(nm)
        else:
            len_nm_vals.append(nm)

    a = pm_vals[0] if pm_vals else None              # explicit pixel size (pm)
    # Field of view is the largest spatial length in the footer.
    b = (max(len_nm_vals) / image_width) if (len_nm_vals and image_width) else None

    if _plausible(a):
        if b is not None and abs(a - b) / max(a, b) > 0.35:
            # A and B disagree strongly — A is the labelled pixel size, keep it
            # but note the mismatch for transparency.
            return a, "ocr_pixel_size", f"OCR footer pixel size ({a:.4f} nm/px); FOV cross-check differs"
        return a, "ocr_pixel_size", f"OCR footer pixel size: {a:.4f} nm/px"
    if _plausible(b):
        return b, "ocr_fov", f"OCR footer field-of-view / {image_width}px: {b:.4f} nm/px"
    return None, None, None


def _detect_scale_bar_line(img):
    """
    Tertiary fallback: find a drawn scale bar (a wide, thin bright/dark bar near
    the bottom) and OCR its length label. Strict on purpose — a wrong scale is
    worse than none — so it only returns a value when a unit label is actually
    read next to a plausibly-sized bar.
    """
    h, w = img.shape[:2]
    strip_top = int(h * 0.8)
    bottom = img[strip_top:, :]

    for invert in (False, True):
        b = (255 - bottom) if invert else bottom
        _, thresh = cv2.threshold(b, 200, 255, cv2.THRESH_BINARY)
        contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

        best = None
        best_w = 0
        for cnt in contours:
            x, y, cw, ch = cv2.boundingRect(cnt)
            # Wide, thin, and not absurdly long (reject rod rows / artefacts).
            if cw > max(40, w * 0.03) and ch < 25 and cw / max(ch, 1) > 6 and cw < w * 0.45:
                if cw > best_w:
                    best_w = cw
                    best = (x, y + strip_top, cw, ch)
        if not best:
            continue

        lx, ly, lw, lh = best
        ty1, ty2 = max(0, ly - 90), min(h, ly + lh + 25)
        tx1, tx2 = max(0, lx - 40), min(w, lx + lw + 140)
        label_crop = img[ty1:ty2, tx1:tx2]
        for psm in (7, 11):
            label = _ocr_text(label_crop, psm=psm)
            m = re.search(r'([\d]+\.?[\d]*)\s*(pm|nm|µm|um|microns?)\b',
                          " ".join(label.split()), re.IGNORECASE)
            if not m:
                continue
            nm = _convert_to_nm(float(m.group(1)), m.group(2))
            if nm and lw > 0:
                ps = nm / lw
                if _plausible(ps):
                    return ps, {
                        "method": "ocr_scale_bar",
                        "scale_bar_coords": (lx, ly + lh // 2, lx + lw, ly + lh // 2),
                        "scale_bar_length_nm": nm,
                        "scale_bar_length_px": lw,
                        "description": f"OCR scale bar: {m.group(1)} {m.group(2)} over {lw}px",
                    }
    return None, {}


def detect_burned_in_scale(image_path: Path):
    """
    Auto-calibrate from burned-in text/scale bar in a raster image (JPG/PNG/TIF).
    Returns (pixel_size_nm, calibration_info) or (None, {}). Safe no-op without
    Tesseract.
    """
    if not _tesseract_ready():
        return None, {}

    try:
        img = cv2.imread(str(image_path), cv2.IMREAD_GRAYSCALE)
        if img is None:
            return None, {}
        h, w = img.shape[:2]

        # 1 & 2. Metadata footer text (pixel size / field of view).
        try:
            from autodetect_utils import _detect_footer_top
            footer_top = _detect_footer_top(img)
        except Exception:
            footer_top = h
        # OCR the detected footer band; if footer detection found nothing, fall
        # back to a single generous bottom slice. One OCR pass either way.
        footer_region = img[footer_top:, :] if footer_top < h else img[int(h * 0.88):, :]
        ps, method, desc = _parse_footer_calibration(_ocr_text(footer_region, psm=6), w)
        if ps is not None:
            print(f"[Scale] {desc}")
            return ps, {"method": method, "pixel_size_nm": ps, "description": desc,
                        "scale_bar_coords": None, "scale_bar_length_nm": None,
                        "scale_bar_length_px": None}

        # 3. Drawn scale bar + label.
        ps, info = _detect_scale_bar_line(img)
        if ps is not None:
            print(f"[Scale] {info.get('description')}")
            return ps, info
    except Exception as e:
        print(f"[Scale] burned-in detection failed: {e}")

    return None, {}


def get_pixel_size(image_path: Path):
    """
    Extract pixel size (nm/pixel) from image metadata or scale bar.
    Returns a tuple: (pixel_size_nm, calibration_info)
    calibration_info is a dict with details for visualization (e.g., scale bar coordinates).
    """
    ext = image_path.suffix.lower()
    pixel_size = None
    method = "unknown"
    scale_bar_coords = None  # (x1, y1, x2, y2)
    scale_bar_length_nm = None
    best_line_width_px = None

    # 1. Try Metadata
    try:
        if ext in ['.dm3', '.dm4']:
            dm = nio.read(str(image_path))
            if 'pixelSize' in dm:
                pixel_size = dm['pixelSize'][0]
                method = "metadata_dm"
                print(f"[Scale] DM metadata: {pixel_size} nm/px")

        elif ext == '.emd':
            pixel_size = read_emd_pixel_size(image_path)
            if pixel_size is not None:
                method = "metadata_emd"

        elif ext in ['.tif', '.tiff']:
            pixel_size, method = _read_tiff_pixel_size(image_path)
            if pixel_size is None:
                method = "unknown"

    except Exception as e:
        print(f"Error extracting metadata: {e}")
        import traceback
        traceback.print_exc()

    # 2. Fallback: read calibration burned into the image (footer text / scale
    # bar) via OCR. Only when metadata gave nothing, so calibrated formats are
    # not slowed down.
    description = None
    if pixel_size is None:
        ocr_ps, ocr_info = detect_burned_in_scale(image_path)
        if ocr_ps is not None:
            pixel_size = ocr_ps
            method = ocr_info.get("method", "ocr")
            scale_bar_coords = ocr_info.get("scale_bar_coords")
            scale_bar_length_nm = ocr_info.get("scale_bar_length_nm")
            best_line_width_px = ocr_info.get("scale_bar_length_px")
            description = ocr_info.get("description")

    if pixel_size is None:
        method = "uncalibrated"
        print(f"[Scale] No calibration found for {image_path.name}")
    else:
        print(f"[Scale] Final: {pixel_size} nm/px via {method}")

    calibration_info = {
        "method": method,
        "scale_bar_coords": scale_bar_coords,
        "scale_bar_length_nm": scale_bar_length_nm,
        "scale_bar_length_px": best_line_width_px if scale_bar_coords else None
    }
    if description:
        calibration_info["description"] = description
    if pixel_size is None:
        calibration_info["warning"] = "No calibration found. Measurements are using a placeholder scale until you calibrate manually."

    return pixel_size, calibration_info