talax 0.1.0__py3-none-any.whl

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.
talax/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """Tala X — see beyond the pixel.
2
+
3
+ from pathlib import Path
4
+ from talax import analyze
5
+
6
+ analysis = analyze(Path("photo.jpg"))
7
+ print(analysis.integrity.verdict)
8
+ for result in analysis.results:
9
+ print(result.signal_id, result.state, result.explanation)
10
+ """
11
+
12
+ from importlib.metadata import version as _version
13
+
14
+ from talax.analysis import analyze
15
+ from talax.model import (
16
+ Analysis,
17
+ Context,
18
+ Declaration,
19
+ FileInfo,
20
+ IntegrityAssessment,
21
+ IntegrityVerdict,
22
+ Metadata,
23
+ Observation,
24
+ Result,
25
+ Sky,
26
+ State,
27
+ )
28
+ from talax.signals import Signal, run_signals
29
+
30
+ __version__ = _version("talax")
31
+
32
+ __all__ = [
33
+ "Analysis",
34
+ "Context",
35
+ "Declaration",
36
+ "FileInfo",
37
+ "IntegrityAssessment",
38
+ "IntegrityVerdict",
39
+ "Metadata",
40
+ "Observation",
41
+ "Result",
42
+ "Signal",
43
+ "Sky",
44
+ "State",
45
+ "__version__",
46
+ "analyze",
47
+ "run_signals",
48
+ ]
talax/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """`python -m talax` runs the same CLI as the `talax` command."""
2
+
3
+ from talax.cli import app
4
+
5
+ app()
talax/analysis.py ADDED
@@ -0,0 +1,52 @@
1
+ """Builds the context of a file and runs the signals on it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+
9
+ from talax.extraction import read_metadata
10
+ from talax.fileinfo import read_file_info
11
+ from talax.integrity import assess_integrity
12
+ from talax.jpeg import JpegError, JpegStructure, read_jpeg_structure
13
+ from talax.model import Analysis, Context, Declaration, Observation
14
+ from talax.provenance import read_c2pa
15
+ from talax.signals import DEFAULT_SIGNALS, Signal, run_signals
16
+ from talax.traits import read_traits
17
+
18
+
19
+ def analyze(
20
+ path: Path,
21
+ declaration: Declaration | None = None,
22
+ observation: Observation | None = None,
23
+ signals: Iterable[Signal] = DEFAULT_SIGNALS,
24
+ ) -> Analysis:
25
+ file = read_file_info(path)
26
+ metadata = read_metadata(path)
27
+ traits = read_traits(file, metadata, _jpeg_structure(path, file.real_format))
28
+ ctx = Context(
29
+ file=file,
30
+ metadata=metadata,
31
+ integrity=assess_integrity(traits),
32
+ declaration=declaration,
33
+ c2pa=read_c2pa(path),
34
+ observation=observation,
35
+ )
36
+ return Analysis(
37
+ file=file,
38
+ integrity=ctx.integrity,
39
+ results=run_signals(ctx, signals),
40
+ created_at=datetime.now(UTC),
41
+ )
42
+
43
+
44
+ def _jpeg_structure(path: Path, real_format: str | None) -> JpegStructure | None:
45
+ if real_format != "image/jpeg":
46
+ return None
47
+ try:
48
+ return read_jpeg_structure(path)
49
+ except JpegError:
50
+ # A damaged structure leaves the generic rules with less to go on;
51
+ # it is not a reason to stop the analysis.
52
+ return None
talax/cli.py ADDED
@@ -0,0 +1,140 @@
1
+ """Command-line interface: a thin adapter over the analysis core."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Annotated
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+ from rich.text import Text
15
+
16
+ from talax.analysis import analyze
17
+ from talax.extraction import ExifToolError
18
+ from talax.model import Analysis, Observation, Sky, State
19
+ from talax.report import DISCLAIMER, STATE_LABELS, VERDICT_LABELS, render_html, to_dict
20
+
21
+ BANNER = r"""
22
+ _____ _ __ __
23
+ |_ _| __ _ | | __ _ \ \/ /
24
+ | | / _` || | / _` | \ /
25
+ | | | (_| || || (_| | / \
26
+ |_| \__,_||_| \__,_| /_/\_\
27
+
28
+ See beyond the pixel
29
+ """
30
+
31
+ _CONTROL = re.compile(r"[\x00-\x09\x0b-\x1f\x7f-\x9f]")
32
+
33
+ _STYLES = {
34
+ State.CONSISTENT: "green",
35
+ State.TO_CHECK: "yellow",
36
+ State.SUSPICIOUS: "bold red",
37
+ State.UNVERIFIABLE: "dim",
38
+ }
39
+
40
+ app = typer.Typer(
41
+ help="Tala X: what can honestly be said about an image.",
42
+ add_completion=False,
43
+ )
44
+ out = Console()
45
+ err = Console(stderr=True)
46
+
47
+
48
+ @app.callback(invoke_without_command=True)
49
+ def main(ctx: typer.Context) -> None:
50
+ """Tala X: what can honestly be said about an image."""
51
+ if ctx.invoked_subcommand is None:
52
+ if sys.stdout.isatty():
53
+ out.print(BANNER, highlight=False, markup=False)
54
+ typer.echo(ctx.get_help())
55
+
56
+
57
+ @app.command(name="analyze")
58
+ def analyze_command(
59
+ file: Annotated[
60
+ Path,
61
+ typer.Argument(exists=True, dir_okay=False, readable=True, help="Image file."),
62
+ ],
63
+ sky: Annotated[
64
+ Sky | None,
65
+ typer.Option(help="What the photo shows, for the solar check."),
66
+ ] = None,
67
+ json_output: Annotated[
68
+ bool, typer.Option("--json", help="Print the analysis as JSON.")
69
+ ] = False,
70
+ html: Annotated[
71
+ Path | None,
72
+ typer.Option(dir_okay=False, help="Also write a self-contained HTML report."),
73
+ ] = None,
74
+ ) -> None:
75
+ """Analyse one file. Nothing is sent over the network."""
76
+ try:
77
+ analysis = analyze(file, observation=Observation(sky) if sky else None)
78
+ except (OSError, ExifToolError) as exc:
79
+ err.print(_safe(f"error: {exc}"), style="red")
80
+ raise typer.Exit(1) from exc
81
+
82
+ if html is not None:
83
+ html.write_text(render_html(analysis, file), encoding="utf-8")
84
+
85
+ if json_output:
86
+ print(json.dumps(to_dict(analysis, file.name), indent=2))
87
+ else:
88
+ _print_text(analysis, file, sky is not None)
89
+ if html is not None:
90
+ err.print(_safe(f"HTML report written to {html}"))
91
+
92
+
93
+ @app.command()
94
+ def version() -> None:
95
+ """Show the versions of Tala X and ExifTool."""
96
+ from talax.report import tool_versions
97
+
98
+ versions = tool_versions()
99
+ print(f"talax {versions['talax']} (ExifTool {versions['exiftool']})")
100
+
101
+
102
+ def _safe(text: str) -> Text:
103
+ """File metadata is written by anyone: no markup, no terminal control codes."""
104
+ return Text(_CONTROL.sub("", text))
105
+
106
+
107
+ def _print_text(analysis: Analysis, file: Path, sky_given: bool) -> None:
108
+ if sys.stdout.isatty():
109
+ out.print(BANNER, highlight=False, markup=False)
110
+ f = analysis.file
111
+ out.print(
112
+ _safe(f"{file.name} {f.size:,} bytes {f.real_format or ''}"), style="bold"
113
+ )
114
+ out.print(_safe(f"SHA-256 {f.sha256}"), style="dim")
115
+ out.print()
116
+ verdict = VERDICT_LABELS[analysis.integrity.verdict]
117
+ out.print(_safe(f"Integrity: {verdict}"), style="bold")
118
+ out.print(_safe(analysis.integrity.explanation))
119
+ out.print()
120
+
121
+ table = Table(box=None, show_header=False, padding=(0, 1, 1, 0), expand=True)
122
+ table.add_column(no_wrap=True)
123
+ table.add_column(ratio=1)
124
+ for r in analysis.results:
125
+ body = _safe(r.signal_id)
126
+ body.stylize("bold")
127
+ body.append("\n")
128
+ body.append_text(_safe(r.explanation))
129
+ table.add_row(Text(STATE_LABELS[r.state], style=_STYLES[r.state]), body)
130
+ out.print(table)
131
+
132
+ if not sky_given and any(
133
+ r.signal_id == "solar" and "sun was" in r.explanation for r in analysis.results
134
+ ):
135
+ out.print(
136
+ "Tip: add --sky day|twilight|night to check the sun against the photo.",
137
+ style="cyan",
138
+ markup=False,
139
+ )
140
+ out.print(_safe(DISCLAIMER), style="dim")
talax/clock.py ADDED
@@ -0,0 +1,72 @@
1
+ """Reading the date and time fields ExifTool returns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import UTC, datetime, timedelta, timezone
7
+
8
+ from talax.model import Metadata
9
+
10
+ _STAMP = re.compile(
11
+ r"^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})(\.\d+)?"
12
+ r"(Z|[+-]\d{2}:\d{2})?$"
13
+ )
14
+
15
+
16
+ def parse(value: object) -> datetime | None:
17
+ if not isinstance(value, str):
18
+ return None
19
+ m = _STAMP.match(value.strip())
20
+ if not m:
21
+ return None
22
+ y, mo, d, h, mi, s, frac, tz = m.groups()
23
+ try:
24
+ dt = datetime(int(y), int(mo), int(d), int(h), int(mi), int(s))
25
+ except ValueError:
26
+ return None
27
+ if frac:
28
+ dt += timedelta(seconds=float(frac))
29
+ if tz == "Z":
30
+ return dt.replace(tzinfo=UTC)
31
+ if tz:
32
+ return dt.replace(tzinfo=offset_from_text(tz))
33
+ return dt
34
+
35
+
36
+ def local(tags: Metadata, key: str, subsec_key: str | None = None) -> datetime | None:
37
+ dt = parse(tags.get(key))
38
+ if dt is None:
39
+ return None
40
+ dt = dt.replace(tzinfo=None)
41
+ subsec = tags.get(subsec_key) if subsec_key else None
42
+ if isinstance(subsec, int | str) and str(subsec).isdigit():
43
+ dt += timedelta(seconds=float(f"0.{subsec}"))
44
+ return dt
45
+
46
+
47
+ def recorded_offset(
48
+ tags: Metadata, vendor: datetime | None = None
49
+ ) -> tuple[timezone | None, str | None]:
50
+ """UTC offset recorded by the device, and where it was found."""
51
+ if vendor is None:
52
+ vendor = parse(tags.get("Samsung:TimeStamp"))
53
+ raw = tags.get("ExifIFD:OffsetTimeOriginal")
54
+ if isinstance(raw, str) and re.fullmatch(r"[+-]\d{2}:\d{2}", raw.strip()):
55
+ return offset_from_text(raw.strip()), "the EXIF offset"
56
+ if vendor is not None and isinstance(vendor.tzinfo, timezone):
57
+ return vendor.tzinfo, "the manufacturer's clock"
58
+ return None, None
59
+
60
+
61
+ def offset_from_text(text: str) -> timezone:
62
+ sign = -1 if text[0] == "-" else 1
63
+ hours, minutes = int(text[1:3]), int(text[4:6])
64
+ return timezone(sign * timedelta(hours=hours, minutes=minutes))
65
+
66
+
67
+ def format_offset(tz: timezone | None) -> str | None:
68
+ if tz is None:
69
+ return None
70
+ total = int(tz.utcoffset(None).total_seconds() // 60)
71
+ sign = "-" if total < 0 else "+"
72
+ return f"{sign}{abs(total) // 60:02d}:{abs(total) % 60:02d}"
@@ -0,0 +1,19 @@
1
+ # Image editors recognised in software tags (IFD0:Software,
2
+ # XMP-xmp:CreatorTool, XMP-xmpMM:HistorySoftwareAgent).
3
+ # Matching is a case-insensitive substring search on each entry.
4
+ # Add an editor only after seeing the exact string it writes.
5
+
6
+ editors:
7
+ - Adobe Photoshop
8
+ - Adobe Lightroom
9
+ - Lightroom
10
+ - GIMP
11
+ - Snapseed
12
+ - PicsArt
13
+ - Canva
14
+ - Pixelmator
15
+ - Affinity Photo
16
+ - Paint.NET
17
+ - darktable
18
+ - Capture One
19
+ - Luminar
@@ -0,0 +1,36 @@
1
+ # Fingerprint of a photo edited and saved in the Samsung Gallery app.
2
+ # Format: see src/talax/fingerprints.py. Every field below is required.
3
+
4
+ id: samsung-gallery-edit
5
+ platform: Samsung Gallery
6
+ route: photo edited and saved on the phone
7
+ summary: >-
8
+ the camera metadata was kept, but the image was re-encoded by the Samsung
9
+ Gallery editor (JFIF header added, byte order changed, embedded preview and
10
+ network country code removed). The content may have been changed in any
11
+ way the editor allows: filters, stickers, text, object removal.
12
+ verdict: undetermined
13
+
14
+ match:
15
+ formats: [image/jpeg]
16
+ exif: present
17
+ vendor_data: present
18
+ progressive: false
19
+ segments: [SOI, APP1:Exif, APP0:JFIF, DQT, DQT, SOF0, DHT, DHT, DHT, DHT, DRI, SOS]
20
+ tags:
21
+ File:ExifByteOrder: MM
22
+
23
+ # Not part of the match, but observed on all 4 samples: no IFD1 (embedded
24
+ # preview removed), Samsung:MCCData removed, Samsung:TimeStamp kept, pixels
25
+ # rotated upright (orientation normal). Originals from the same phone are
26
+ # little-endian (II) and have no JFIF header.
27
+
28
+ measured:
29
+ date: 2026-10-31
30
+ samples: 4
31
+ devices: [samsung Galaxy A34 5G]
32
+ app_versions: [unknown]
33
+ retrieval: bluetooth (2 files) and usb-cable (2 files)
34
+ corpus_sets: [2026-10-31-a34-edits]
35
+
36
+ distinguished_from: []
@@ -0,0 +1,39 @@
1
+ # Fingerprint of a photo sent through WhatsApp as a gallery image.
2
+ # Format: see src/talax/fingerprints.py. Every field below is required.
3
+
4
+ id: whatsapp-gallery
5
+ platform: WhatsApp
6
+ route: photo sent from the gallery (standard quality)
7
+ summary: >-
8
+ the camera metadata is gone and the image was re-encoded the way WhatsApp
9
+ does it.
10
+ verdict: reprocessed
11
+
12
+ # All conditions must hold for a file to match.
13
+ match:
14
+ formats: [image/jpeg]
15
+ exif: absent
16
+ vendor_data: absent
17
+ progressive: true
18
+ segments: [SOI, APP0:JFIF, DQT, SOF2, DHT, SOS]
19
+ tags:
20
+ JFIF:JFIFVersion: "1 1"
21
+ JFIF:ResolutionUnit: 0
22
+ JFIF:XResolution: 1
23
+ JFIF:YResolution: 1
24
+
25
+ # Not part of the match: quantisation tables differ between the two devices
26
+ # measured (first coefficient 10 from the A34, 6 from the S22), and so do the
27
+ # output dimensions (1000 or 1080 px long side from the A34, 1280 from the S22).
28
+
29
+ measured:
30
+ date: 2026-10-31
31
+ samples: 13
32
+ devices: [samsung Galaxy A34 5G, samsung Galaxy S22 (SM-S901B)]
33
+ app_versions: [unknown]
34
+ retrieval: WhatsApp Web download
35
+ corpus_sets: [2026-10-31-a34, 2026-10-31-s22]
36
+
37
+ # Platforms measured that do NOT match this fingerprint. Empty means no other
38
+ # platform has been measured yet, and the report must say so.
39
+ distinguished_from: []
@@ -0,0 +1,14 @@
1
+ # ExifTool groups (-G1 names) that only a device manufacturer writes.
2
+ # Their presence is one of the marks of a camera original.
3
+ #
4
+ # Listed only after being observed in a camera original of the corpus: an
5
+ # unknown group is NOT treated as manufacturer data, so an unmeasured device
6
+ # gives "undetermined", never a false "original". (Windows Photos adds the
7
+ # generic XMP-rdf block, which an earlier "everything not standard" rule
8
+ # wrongly counted.)
9
+
10
+ vendor_groups:
11
+ - Samsung # Samsung trailer (A34, S22)
12
+ - Google # Google container data (S22 motion photo)
13
+ - XMP-GCamera # S22
14
+ - XMP-GContainer # S22
talax/extraction.py ADDED
@@ -0,0 +1,107 @@
1
+ """Metadata extraction through ExifTool, an external program."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import json
7
+ import shutil
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from talax.model import Metadata
13
+
14
+ MIN_EXIFTOOL_VERSION = (13, 0)
15
+ _TIMEOUT_SECONDS = 60
16
+
17
+ # -G1: precise group (`ExifIFD:DateTimeOriginal`), so a tag forged in another
18
+ # block shows up separately. -a: keep duplicates. -n: raw numeric values.
19
+ # System tags (file name, directory, dates, permissions) describe the local
20
+ # copy, not the content, and may leak private paths: excluded.
21
+ _ARGS = ("-json", "-G1", "-a", "-n", "--System:all")
22
+
23
+
24
+ class ExifToolError(RuntimeError):
25
+ pass
26
+
27
+
28
+ class ExifToolMissingError(ExifToolError):
29
+ pass
30
+
31
+
32
+ @functools.cache
33
+ def exiftool_version() -> tuple[int, int]:
34
+ """Installed ExifTool version. Raises if missing or too old."""
35
+ if shutil.which("exiftool") is None:
36
+ raise ExifToolMissingError(
37
+ "ExifTool is required but was not found. Install it from "
38
+ "https://exiftool.org/ and make sure `exiftool` is on your PATH."
39
+ )
40
+ out = _run(["-ver"]).strip()
41
+ try:
42
+ major, minor = (int(part) for part in out.split(".")[:2])
43
+ except ValueError as exc:
44
+ raise ExifToolError(f"unexpected ExifTool version output: {out!r}") from exc
45
+ if (major, minor) < MIN_EXIFTOOL_VERSION:
46
+ wanted = ".".join(map(str, MIN_EXIFTOOL_VERSION))
47
+ raise ExifToolError(
48
+ f"ExifTool {out} is too old; Tala X needs {wanted} or newer. Older "
49
+ "versions miss vendor tags and would give different results."
50
+ )
51
+ return major, minor
52
+
53
+
54
+ def read_metadata(path: Path) -> Metadata:
55
+ exiftool_version()
56
+ # An absolute path cannot be mistaken for an option (`-name.jpg`).
57
+ out = _run([*_ARGS, str(path.resolve())])
58
+ try:
59
+ (record,) = json.loads(out, object_pairs_hook=_keep_duplicates)
60
+ except ValueError as exc:
61
+ raise ExifToolError("could not parse ExifTool output") from exc
62
+ record.pop("SourceFile", None) # local path: private, not content
63
+ return Metadata(record)
64
+
65
+
66
+ def read_thumbnail(path: Path) -> bytes | None:
67
+ """Embedded EXIF preview image (IFD1), or None if the file has none."""
68
+ exiftool_version()
69
+ try:
70
+ proc = subprocess.run(
71
+ ["exiftool", "-b", "-ThumbnailImage", str(path.resolve())],
72
+ capture_output=True,
73
+ timeout=_TIMEOUT_SECONDS,
74
+ check=False,
75
+ )
76
+ except subprocess.TimeoutExpired:
77
+ return None
78
+ return proc.stdout or None
79
+
80
+
81
+ def _run(args: list[str]) -> str:
82
+ try:
83
+ proc = subprocess.run(
84
+ ["exiftool", *args],
85
+ capture_output=True,
86
+ text=True,
87
+ encoding="utf-8",
88
+ timeout=_TIMEOUT_SECONDS,
89
+ check=False,
90
+ )
91
+ except subprocess.TimeoutExpired as exc:
92
+ raise ExifToolError(f"ExifTool timed out after {_TIMEOUT_SECONDS}s") from exc
93
+ if proc.returncode != 0 and not proc.stdout:
94
+ raise ExifToolError(proc.stderr.strip() or "ExifTool failed")
95
+ return proc.stdout
96
+
97
+
98
+ def _keep_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
99
+ """Same tag twice in the same group is itself evidence: never drop one."""
100
+ result: dict[str, Any] = {}
101
+ for key, value in pairs:
102
+ name, n = key, 1
103
+ while name in result:
104
+ n += 1
105
+ name = f"{key}#{n}"
106
+ result[name] = value
107
+ return result
talax/fileinfo.py ADDED
@@ -0,0 +1,90 @@
1
+ """File identity: hash, format implied by the name, format read from the bytes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from pathlib import Path
7
+
8
+ from talax.model import FileInfo
9
+
10
+ _CHUNK = 1024 * 1024
11
+ _SNIFF_SIZE = 32
12
+
13
+ EXTENSIONS = {
14
+ ".jpg": "image/jpeg",
15
+ ".jpeg": "image/jpeg",
16
+ ".jpe": "image/jpeg",
17
+ ".png": "image/png",
18
+ ".gif": "image/gif",
19
+ ".webp": "image/webp",
20
+ ".tif": "image/tiff",
21
+ ".tiff": "image/tiff",
22
+ ".heic": "image/heic",
23
+ ".heif": "image/heif",
24
+ ".avif": "image/avif",
25
+ ".mp4": "video/mp4",
26
+ ".m4v": "video/mp4",
27
+ ".mov": "video/quicktime",
28
+ }
29
+
30
+ # ISO base media files (HEIC, AVIF, MP4, MOV) all start with an `ftyp` box;
31
+ # its major brand tells them apart.
32
+ _FTYP_BRANDS = {
33
+ b"heic": "image/heic",
34
+ b"heix": "image/heic",
35
+ b"heim": "image/heic",
36
+ b"heis": "image/heic",
37
+ b"hevc": "image/heic",
38
+ b"hevx": "image/heic",
39
+ b"mif1": "image/heif",
40
+ b"msf1": "image/heif",
41
+ b"avif": "image/avif",
42
+ b"avis": "image/avif",
43
+ b"qt ": "video/quicktime",
44
+ b"isom": "video/mp4",
45
+ b"iso2": "video/mp4",
46
+ b"iso4": "video/mp4",
47
+ b"iso5": "video/mp4",
48
+ b"iso6": "video/mp4",
49
+ b"mp41": "video/mp4",
50
+ b"mp42": "video/mp4",
51
+ b"avc1": "video/mp4",
52
+ b"M4V ": "video/mp4",
53
+ b"3gp4": "video/3gpp",
54
+ b"3gp5": "video/3gpp",
55
+ }
56
+
57
+
58
+ def read_file_info(path: Path) -> FileInfo:
59
+ digest = hashlib.sha256()
60
+ size = 0
61
+ with path.open("rb") as f:
62
+ head = f.read(_SNIFF_SIZE)
63
+ digest.update(head)
64
+ size += len(head)
65
+ while chunk := f.read(_CHUNK):
66
+ digest.update(chunk)
67
+ size += len(chunk)
68
+ return FileInfo(
69
+ sha256=digest.hexdigest(),
70
+ size=size,
71
+ declared_format=EXTENSIONS.get(path.suffix.lower()),
72
+ real_format=sniff_format(head),
73
+ )
74
+
75
+
76
+ def sniff_format(head: bytes) -> str | None:
77
+ """MIME type from the first bytes of a file, or None if unrecognised."""
78
+ if head.startswith(b"\xff\xd8\xff"):
79
+ return "image/jpeg"
80
+ if head.startswith(b"\x89PNG\r\n\x1a\n"):
81
+ return "image/png"
82
+ if head[:6] in (b"GIF87a", b"GIF89a"):
83
+ return "image/gif"
84
+ if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
85
+ return "image/webp"
86
+ if head[:4] in (b"II*\x00", b"MM\x00*"):
87
+ return "image/tiff"
88
+ if head[4:8] == b"ftyp":
89
+ return _FTYP_BRANDS.get(head[8:12])
90
+ return None