Initial JPEG-LS FPGA encoder baseline with tooling and timeout fix
This commit is contained in:
82
tools/jls_compat/README.md
Normal file
82
tools/jls_compat/README.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# JPEG-LS Compatibility Probes
|
||||
|
||||
This directory contains small experiments that must run before RTL work starts.
|
||||
|
||||
## duplicate_sos_probe.py
|
||||
|
||||
Purpose: check whether a one-component JPEG-LS frame can contain a second SOS
|
||||
marker for the same component. This is related to the SRS idea of representing
|
||||
horizontal strips as multiple scans inside one grayscale frame.
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
python -m pip install --target tools/jls_compat/.deps imagecodecs
|
||||
$env:JLS_COMPAT_PYDEPS = (Resolve-Path tools/jls_compat/.deps).Path
|
||||
python tools/jls_compat/duplicate_sos_probe.py
|
||||
```
|
||||
|
||||
Expected result: the original one-component stream decodes, and the stream with
|
||||
a second SOS marker is rejected. With imagecodecs/CharLS 2.4.1 the observed
|
||||
error is "missing End Of Image (EOI) marker". This supports using the per-strip
|
||||
standalone frame fallback unless a stricter CharLS 3.x experiment proves
|
||||
otherwise.
|
||||
|
||||
Static source note: current CharLS source increments `read_component_count_` in
|
||||
`jpeg_stream_reader::read_start_of_scan_segment()` and rejects an SOS if its
|
||||
component count exceeds the frame components that have not already been read.
|
||||
|
||||
## reference_decode_compare.py
|
||||
|
||||
Purpose: decode an RTL-generated `.jls` stream with reference decoders. The
|
||||
script always tries CharLS through `imagecodecs`; when a jpeg.org/libjpeg
|
||||
`jpeg` executable is available, it also runs libjpeg and compares decoded
|
||||
pixels.
|
||||
|
||||
Install Python dependencies:
|
||||
|
||||
```powershell
|
||||
python -m pip install --target tools/jls_compat/.deps imagecodecs
|
||||
$env:JLS_COMPAT_PYDEPS = (Resolve-Path tools/jls_compat/.deps).Path
|
||||
```
|
||||
|
||||
Run smoke decode:
|
||||
|
||||
```powershell
|
||||
python tools/jls_compat/reference_decode_compare.py path/to/output.jls
|
||||
```
|
||||
|
||||
Run smoke decode for a concatenated strip-frame stream:
|
||||
|
||||
```powershell
|
||||
python tools/jls_compat/reference_decode_compare.py path/to/output_stream.jls --split-frames --expected-frames 16
|
||||
```
|
||||
|
||||
Run mature regression with libjpeg required:
|
||||
|
||||
```powershell
|
||||
$env:LIBJPEG_JPEG_EXE = (Resolve-Path third_party/libjpeg/jpeg.exe).Path
|
||||
python tools/jls_compat/reference_decode_compare.py path/to/output.jls --require-libjpeg
|
||||
```
|
||||
|
||||
libjpeg command line note: the jpeg.org/libjpeg README recommends decoding
|
||||
JPEG-LS with `jpeg -c input.jpg output.ppm`, where `-c` disables the color
|
||||
transformation. The script uses that form.
|
||||
|
||||
## make_strip_stream_smoke.py
|
||||
|
||||
Purpose: generate a concatenated standalone strip-frame stream for validation
|
||||
tool smoke tests. It also writes a reference PGM, SRS-style big-endian raw file,
|
||||
and JSON metadata.
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
python tools/jls_compat/make_strip_stream_smoke.py --width 32 --height 32 --strip-rows 16 --bit-depth 8 --name strip_smoke_8b
|
||||
python tools/jls_compat/reference_decode_compare.py tools/jls_compat/out/strip_smoke_8b.jls --split-frames --expected-frames 2 --reference-pgm tools/jls_compat/out/strip_smoke_8b.pgm
|
||||
```
|
||||
|
||||
Note: 10/12/14-bit smoke streams are generated with values constrained to the
|
||||
requested bit depth, but the imagecodecs encoder uses a `uint16` container and
|
||||
may write 16-bit precision in the JPEG-LS header. Use those streams to test the
|
||||
tool flow only, not as an RTL SOF precision oracle.
|
||||
159
tools/jls_compat/build_single_frame_multiscan_experiment.py
Normal file
159
tools/jls_compat/build_single_frame_multiscan_experiment.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Build an experimental single-frame multi-scan JPEG-LS stream.
|
||||
|
||||
This is an interoperability experiment only. It takes one grayscale PGM image,
|
||||
splits it into fixed-height strips, encodes each strip with CharLS, and then
|
||||
splices the strip codestreams into one byte stream with:
|
||||
|
||||
SOI + frame header + first SOS/payload + second SOS/payload + ... + EOI
|
||||
|
||||
The resulting file is useful for checking whether decoders accept repeated SOS
|
||||
markers for the same single grayscale component inside one JPEG-LS frame.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
JPEG_SOI = b"\xff\xd8"
|
||||
JPEG_EOI = b"\xff\xd9"
|
||||
JPEG_SOF55 = b"\xff\xf7"
|
||||
JPEG_SOS = b"\xff\xda"
|
||||
JPEG_LS_INTERCHANGE_PREFIX = JPEG_SOI + JPEG_SOF55
|
||||
|
||||
|
||||
def add_optional_dependency_path() -> None:
|
||||
dep_path = os.environ.get("JLS_COMPAT_PYDEPS")
|
||||
if dep_path:
|
||||
sys.path.insert(0, dep_path)
|
||||
return
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
bundled_dep = repo_root / "tools" / "jls_compat" / ".deps"
|
||||
sys.path.insert(0, str(bundled_dep))
|
||||
|
||||
|
||||
def read_non_comment_line(handle) -> bytes:
|
||||
while True:
|
||||
line = handle.readline()
|
||||
if not line:
|
||||
raise ValueError("unexpected EOF while reading PGM header")
|
||||
if not line.startswith(b"#"):
|
||||
return line
|
||||
|
||||
|
||||
def read_pgm(path: Path):
|
||||
import numpy as np
|
||||
|
||||
with path.open("rb") as handle:
|
||||
magic = read_non_comment_line(handle).strip()
|
||||
if magic != b"P5":
|
||||
raise ValueError(f"{path} is not a binary PGM file")
|
||||
|
||||
dims = read_non_comment_line(handle).strip().split()
|
||||
if len(dims) != 2:
|
||||
raise ValueError(f"{path} has invalid dimension line")
|
||||
|
||||
width = int(dims[0])
|
||||
height = int(dims[1])
|
||||
max_value = int(read_non_comment_line(handle).strip())
|
||||
payload = handle.read()
|
||||
|
||||
if max_value <= 255:
|
||||
expected_size = width * height
|
||||
if len(payload) != expected_size:
|
||||
raise ValueError(f"{path} payload {len(payload)} != {expected_size}")
|
||||
image = np.frombuffer(payload, dtype=np.uint8).reshape((height, width))
|
||||
else:
|
||||
expected_size = width * height * 2
|
||||
if len(payload) != expected_size:
|
||||
raise ValueError(f"{path} payload {len(payload)} != {expected_size}")
|
||||
image = np.frombuffer(payload, dtype=">u2").reshape((height, width)).astype(np.uint16)
|
||||
|
||||
return image, width, height, max_value
|
||||
|
||||
|
||||
def normalize_to_interchange_codestream(encoded: bytes) -> bytes:
|
||||
if encoded.startswith(JPEG_LS_INTERCHANGE_PREFIX):
|
||||
if not encoded.endswith(JPEG_EOI):
|
||||
raise ValueError("JPEG-LS codestream is missing EOI")
|
||||
return encoded
|
||||
|
||||
spiff_payload_offset = encoded.find(JPEG_LS_INTERCHANGE_PREFIX, len(JPEG_SOI))
|
||||
if spiff_payload_offset < 0:
|
||||
raise ValueError("failed to locate embedded JPEG-LS interchange stream")
|
||||
|
||||
normalized = encoded[spiff_payload_offset:]
|
||||
if not normalized.endswith(JPEG_EOI):
|
||||
raise ValueError("embedded JPEG-LS codestream is missing EOI")
|
||||
return normalized
|
||||
|
||||
|
||||
def find_marker(data: bytes, marker: bytes) -> int:
|
||||
index = data.find(marker)
|
||||
if index < 0:
|
||||
raise ValueError(f"marker {marker.hex()} not found")
|
||||
return index
|
||||
|
||||
|
||||
def find_last_marker(data: bytes, marker: bytes) -> int:
|
||||
index = data.rfind(marker)
|
||||
if index < 0:
|
||||
raise ValueError(f"marker {marker.hex()} not found")
|
||||
return index
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--input-pgm", type=Path, required=True)
|
||||
parser.add_argument("--strip-rows", type=int, default=16)
|
||||
parser.add_argument("--level", type=int, default=0, help="CharLS near-lossless level")
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
add_optional_dependency_path()
|
||||
|
||||
import imagecodecs
|
||||
|
||||
args = parse_args()
|
||||
image, width, height, _ = read_pgm(args.input_pgm.resolve())
|
||||
|
||||
if height % args.strip_rows != 0:
|
||||
raise ValueError("image height must be an integer multiple of strip rows")
|
||||
|
||||
strip_streams: list[bytes] = []
|
||||
for row_start in range(0, height, args.strip_rows):
|
||||
strip = image[row_start : row_start + args.strip_rows, :]
|
||||
encoded = imagecodecs.jpegls_encode(strip, level=args.level)
|
||||
strip_streams.append(normalize_to_interchange_codestream(encoded))
|
||||
|
||||
first_stream = strip_streams[0]
|
||||
first_eoi = find_last_marker(first_stream, JPEG_EOI)
|
||||
merged = bytearray(first_stream[:first_eoi])
|
||||
|
||||
for stream in strip_streams[1:]:
|
||||
sos_index = find_marker(stream, JPEG_SOS)
|
||||
eoi_index = find_last_marker(stream, JPEG_EOI)
|
||||
merged.extend(stream[sos_index:eoi_index])
|
||||
|
||||
merged.extend(JPEG_EOI)
|
||||
|
||||
output_path = args.output.resolve()
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(bytes(merged))
|
||||
|
||||
print(f"input={args.input_pgm}")
|
||||
print(f"width={width} height={height} strip_rows={args.strip_rows} strips={len(strip_streams)}")
|
||||
print(f"charls={imagecodecs.jpegls_version()} level={args.level}")
|
||||
print(f"output={output_path}")
|
||||
print(f"output_bytes={len(merged)} sos_count={bytes(merged).count(JPEG_SOS)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
105
tools/jls_compat/duplicate_sos_probe.py
Normal file
105
tools/jls_compat/duplicate_sos_probe.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Probe repeated SOS handling for a one-component JPEG-LS stream.
|
||||
|
||||
The FPGA SRS considered using multiple scans inside one grayscale JPEG-LS
|
||||
frame to represent horizontal strips with different NEAR values. JPEG-LS
|
||||
has no per-scan strip-height field, and CharLS tracks how many frame
|
||||
components have already appeared in SOS markers. This probe creates a valid
|
||||
one-component JPEG-LS stream, injects a second SOS marker before EOI, and
|
||||
checks whether the decoder accepts it.
|
||||
|
||||
Dependency:
|
||||
python -m pip install --target tools/jls_compat/.deps imagecodecs
|
||||
set JLS_COMPAT_PYDEPS=tools/jls_compat/.deps
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def add_optional_dependency_path() -> None:
|
||||
"""Allow local dependency installs without changing the global Python env."""
|
||||
|
||||
dep_path = os.environ.get("JLS_COMPAT_PYDEPS")
|
||||
if dep_path:
|
||||
sys.path.insert(0, dep_path)
|
||||
|
||||
|
||||
def find_marker(data: bytes, marker: bytes) -> int:
|
||||
"""Return the index of a required JPEG marker."""
|
||||
|
||||
marker_index = data.find(marker)
|
||||
if marker_index < 0:
|
||||
raise ValueError(f"marker {marker.hex()} was not found")
|
||||
return marker_index
|
||||
|
||||
|
||||
def find_last_marker(data: bytes, marker: bytes) -> int:
|
||||
"""Return the index of a required JPEG marker searched from the end."""
|
||||
|
||||
marker_index = data.rfind(marker)
|
||||
if marker_index < 0:
|
||||
raise ValueError(f"marker {marker.hex()} was not found")
|
||||
return marker_index
|
||||
|
||||
|
||||
def main() -> int:
|
||||
add_optional_dependency_path()
|
||||
|
||||
try:
|
||||
import imagecodecs
|
||||
import numpy as np
|
||||
except ModuleNotFoundError as exc:
|
||||
print("Missing dependency:", exc)
|
||||
print("Install with: python -m pip install --target tools/jls_compat/.deps imagecodecs")
|
||||
print("Then set JLS_COMPAT_PYDEPS to that .deps directory.")
|
||||
return 2
|
||||
|
||||
first_row = np.array([[1, 2, 3, 4]], dtype=np.uint8)
|
||||
second_row = np.array([[5, 6, 7, 8]], dtype=np.uint8)
|
||||
|
||||
encoded_first = imagecodecs.jpegls_encode(first_row)
|
||||
encoded_second = imagecodecs.jpegls_encode(second_row)
|
||||
|
||||
first_eoi_index = find_last_marker(encoded_first, b"\xff\xd9")
|
||||
second_sos_index = find_marker(encoded_second, b"\xff\xda")
|
||||
second_eoi_index = find_last_marker(encoded_second, b"\xff\xd9")
|
||||
|
||||
duplicate_sos_stream = (
|
||||
encoded_first[:first_eoi_index]
|
||||
+ encoded_second[second_sos_index:second_eoi_index]
|
||||
+ encoded_first[first_eoi_index:]
|
||||
)
|
||||
|
||||
out_dir = Path(__file__).resolve().parent / "out"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
(out_dir / "single_component_original.jls").write_bytes(encoded_first)
|
||||
(out_dir / "single_component_duplicate_sos.jls").write_bytes(duplicate_sos_stream)
|
||||
|
||||
sos_count = duplicate_sos_stream.count(b"\xff\xda")
|
||||
|
||||
print(f"decoder backend: {imagecodecs.jpegls_version()}")
|
||||
print(f"original bytes: {len(encoded_first)}")
|
||||
print(f"duplicate-SOS bytes: {len(duplicate_sos_stream)}")
|
||||
print(f"duplicate-SOS marker count: {sos_count}")
|
||||
|
||||
decoded = imagecodecs.jpegls_decode(encoded_first)
|
||||
print(f"original decode: OK, shape={decoded.shape}, dtype={decoded.dtype}")
|
||||
|
||||
try:
|
||||
imagecodecs.jpegls_decode(duplicate_sos_stream)
|
||||
except Exception as exc: # imagecodecs wraps CharLS errors in JpeglsError.
|
||||
print("duplicate-SOS decode: rejected")
|
||||
print(f"error type: {type(exc).__name__}")
|
||||
print(f"error text: {exc}")
|
||||
print("conclusion: do not use repeated SOS markers for one grayscale frame.")
|
||||
return 0
|
||||
|
||||
print("duplicate-SOS decode: unexpectedly accepted")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
173
tools/jls_compat/generate_charls_references.py
Normal file
173
tools/jls_compat/generate_charls_references.py
Normal file
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SOURCE_NAME_RE = re.compile(
|
||||
r"^(?P<name>.+)-w(?P<width>\d+)-h(?P<height>\d+)-s(?P<sign>[01])-b(?P<bit_depth>\d+)\.pgm$"
|
||||
)
|
||||
JPEG_SOI = b"\xff\xd8"
|
||||
JPEG_LS_SOF55 = b"\xff\xf7"
|
||||
JPEG_LS_INTERCHANGE_PREFIX = JPEG_SOI + JPEG_LS_SOF55
|
||||
JPEG_EOI = b"\xff\xd9"
|
||||
|
||||
|
||||
def add_optional_dependency_path() -> None:
|
||||
dep_path = os.environ.get("JLS_COMPAT_PYDEPS")
|
||||
if dep_path:
|
||||
sys.path.insert(0, dep_path)
|
||||
return
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
bundled_dep = repo_root / "tools" / "jls_compat" / ".deps"
|
||||
sys.path.insert(0, str(bundled_dep))
|
||||
|
||||
|
||||
def read_non_comment_line(handle) -> bytes:
|
||||
while True:
|
||||
line = handle.readline()
|
||||
if not line:
|
||||
raise ValueError("unexpected EOF while reading PGM header")
|
||||
if not line.startswith(b"#"):
|
||||
return line
|
||||
|
||||
|
||||
def read_pgm(path: Path):
|
||||
import numpy as np
|
||||
|
||||
with path.open("rb") as handle:
|
||||
magic = read_non_comment_line(handle).strip()
|
||||
if magic != b"P5":
|
||||
raise ValueError(f"{path} is not a binary PGM file")
|
||||
|
||||
dims = read_non_comment_line(handle).strip().split()
|
||||
if len(dims) != 2:
|
||||
raise ValueError(f"{path} has invalid dimension line")
|
||||
|
||||
width = int(dims[0])
|
||||
height = int(dims[1])
|
||||
max_value = int(read_non_comment_line(handle).strip())
|
||||
payload = handle.read()
|
||||
|
||||
if max_value <= 255:
|
||||
expected_size = width * height
|
||||
if len(payload) != expected_size:
|
||||
raise ValueError(f"{path} payload {len(payload)} != {expected_size}")
|
||||
image = np.frombuffer(payload, dtype=np.uint8).reshape((height, width))
|
||||
else:
|
||||
expected_size = width * height * 2
|
||||
if len(payload) != expected_size:
|
||||
raise ValueError(f"{path} payload {len(payload)} != {expected_size}")
|
||||
image = np.frombuffer(payload, dtype=">u2").reshape((height, width)).astype(np.uint16)
|
||||
|
||||
return image, width, height, max_value
|
||||
|
||||
|
||||
def collect_sources(repo_root: Path) -> list[Path]:
|
||||
source_paths = []
|
||||
for directory in [repo_root / "img" / "patterns"]:
|
||||
if not directory.exists():
|
||||
continue
|
||||
source_paths.extend(sorted(directory.glob("*.pgm")))
|
||||
return source_paths
|
||||
|
||||
|
||||
def build_output_name(source_path: Path, ratio: int) -> str:
|
||||
match = SOURCE_NAME_RE.match(source_path.name)
|
||||
if not match:
|
||||
raise ValueError(f"unexpected source filename format: {source_path.name}")
|
||||
|
||||
return (
|
||||
f"{match.group('name')}"
|
||||
f"-w{match.group('width')}"
|
||||
f"-h{match.group('height')}"
|
||||
f"-s{match.group('sign')}"
|
||||
f"-b{match.group('bit_depth')}"
|
||||
f"-r{ratio}.charlsjls"
|
||||
)
|
||||
|
||||
|
||||
def normalize_to_interchange_codestream(encoded: bytes) -> tuple[bytes, bool]:
|
||||
# imagecodecs/CharLS may emit a SPIFF wrapper ahead of the real JPEG-LS
|
||||
# interchange stream. The RTL and WIC compatibility checks both expect the
|
||||
# stored reference file to be the plain SOI..EOI JPEG-LS codestream.
|
||||
if encoded.startswith(JPEG_LS_INTERCHANGE_PREFIX):
|
||||
if not encoded.endswith(JPEG_EOI):
|
||||
raise ValueError("JPEG-LS codestream is missing EOI")
|
||||
return encoded, False
|
||||
|
||||
spiff_payload_offset = encoded.find(JPEG_LS_INTERCHANGE_PREFIX, len(JPEG_SOI))
|
||||
if spiff_payload_offset < 0:
|
||||
raise ValueError("failed to locate embedded JPEG-LS interchange stream")
|
||||
|
||||
normalized = encoded[spiff_payload_offset:]
|
||||
if not normalized.endswith(JPEG_EOI):
|
||||
raise ValueError("embedded JPEG-LS codestream is missing EOI")
|
||||
return normalized, True
|
||||
|
||||
|
||||
def encode_all() -> int:
|
||||
add_optional_dependency_path()
|
||||
|
||||
import imagecodecs
|
||||
import numpy as np
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
out_dir = repo_root / "img" / "reference" / "charls"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sources = collect_sources(repo_root)
|
||||
if not sources:
|
||||
raise ValueError("no source PGM files found under img/patterns")
|
||||
|
||||
ratio = 0
|
||||
manifest = {
|
||||
"encoder": imagecodecs.jpegls_version(),
|
||||
"ratio": ratio,
|
||||
"file_count": len(sources),
|
||||
"files": [],
|
||||
}
|
||||
|
||||
for source_path in sources:
|
||||
image, width, height, max_value = read_pgm(source_path)
|
||||
out_capacity = image.nbytes * 2 + 65536
|
||||
encoded = imagecodecs.jpegls_encode(image, level=0, out=out_capacity)
|
||||
normalized, spiff_stripped = normalize_to_interchange_codestream(encoded)
|
||||
decoded = imagecodecs.jpegls_decode(normalized)
|
||||
|
||||
if decoded.shape != image.shape or not np.array_equal(decoded, image):
|
||||
raise ValueError(f"decode mismatch after encoding {source_path}")
|
||||
|
||||
output_path = out_dir / build_output_name(source_path, ratio)
|
||||
output_path.write_bytes(normalized)
|
||||
|
||||
manifest["files"].append(
|
||||
{
|
||||
"source": str(source_path.relative_to(repo_root)).replace("\\", "/"),
|
||||
"output": str(output_path.relative_to(repo_root)).replace("\\", "/"),
|
||||
"width": width,
|
||||
"height": height,
|
||||
"max_value": max_value,
|
||||
"container": "jpeg-ls-interchange",
|
||||
"spiff_stripped": spiff_stripped,
|
||||
"encoder_output_bytes": len(encoded),
|
||||
"encoded_bytes": len(normalized),
|
||||
}
|
||||
)
|
||||
print(
|
||||
f"encoded {source_path.name} -> {output_path.name} "
|
||||
f"({len(normalized)} bytes, spiff_stripped={spiff_stripped})"
|
||||
)
|
||||
|
||||
manifest_path = out_dir / "manifest.json"
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
||||
print(f"wrote manifest: {manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(encode_all())
|
||||
154
tools/jls_compat/make_strip_stream_smoke.py
Normal file
154
tools/jls_compat/make_strip_stream_smoke.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Create a concatenated JPEG-LS strip-frame smoke stream.
|
||||
|
||||
This generator is for validation-tool smoke testing. It uses imagecodecs
|
||||
CharLS bindings to encode each strip as a standalone JPEG-LS frame, then
|
||||
concatenates the frames in source-image order. For 10/12/14-bit patterns the
|
||||
sample values are constrained to the requested bit depth but encoded through a
|
||||
uint16 container because the imagecodecs encoder infers precision from dtype.
|
||||
Do not use this script as the RTL header/SOF precision oracle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def add_optional_dependency_path() -> None:
|
||||
"""Allow local dependency installs without changing the global Python env."""
|
||||
|
||||
dep_path = os.environ.get("JLS_COMPAT_PYDEPS")
|
||||
if dep_path:
|
||||
sys.path.insert(0, dep_path)
|
||||
|
||||
|
||||
def make_pattern(width: int, height: int, bit_depth: int, pattern: str):
|
||||
"""Return a grayscale numpy image for smoke testing."""
|
||||
|
||||
import numpy as np
|
||||
|
||||
max_value = (1 << bit_depth) - 1
|
||||
x = np.arange(width, dtype=np.uint32)[None, :]
|
||||
y = np.arange(height, dtype=np.uint32)[:, None]
|
||||
|
||||
if pattern == "gradient":
|
||||
data = (x * max(1, max_value // max(1, width - 1)) + y * 3) & max_value
|
||||
elif pattern == "checker":
|
||||
data = (((x // 4) ^ (y // 4)) & 1) * max_value
|
||||
elif pattern == "edge":
|
||||
data = np.where(x < width // 2, max_value // 8, (max_value * 7) // 8)
|
||||
data = np.repeat(data, height, axis=0)
|
||||
elif pattern == "ramp":
|
||||
data = (x + y * width) & max_value
|
||||
else:
|
||||
raise ValueError(f"unsupported pattern {pattern!r}")
|
||||
|
||||
if bit_depth <= 8:
|
||||
return data.astype(np.uint8)
|
||||
return data.astype(np.uint16)
|
||||
|
||||
|
||||
def write_pgm(path: Path, image, bit_depth: int) -> None:
|
||||
"""Write a binary PGM reference image."""
|
||||
|
||||
height, width = image.shape
|
||||
max_value = (1 << bit_depth) - 1
|
||||
header = f"P5\n{width} {height}\n{max_value}\n".encode("ascii")
|
||||
|
||||
if bit_depth <= 8:
|
||||
payload = image.astype("uint8").tobytes()
|
||||
else:
|
||||
payload = image.astype(">u2").tobytes()
|
||||
|
||||
path.write_bytes(header + payload)
|
||||
|
||||
|
||||
def write_raw_big_endian(path: Path, image, bit_depth: int) -> None:
|
||||
"""Write SRS-style raw input data."""
|
||||
|
||||
if bit_depth <= 8:
|
||||
path.write_bytes(image.astype("uint8").tobytes())
|
||||
else:
|
||||
path.write_bytes(image.astype(">u2").tobytes())
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--width", type=int, default=32)
|
||||
parser.add_argument("--height", type=int, default=32)
|
||||
parser.add_argument("--strip-rows", type=int, default=16)
|
||||
parser.add_argument("--bit-depth", type=int, default=8, choices=[8, 10, 12, 14, 16])
|
||||
parser.add_argument("--pattern", default="gradient", choices=["gradient", "checker", "edge", "ramp"])
|
||||
parser.add_argument("--out-dir", type=Path, default=Path("tools/jls_compat/out"))
|
||||
parser.add_argument("--name", default="strip_smoke")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
add_optional_dependency_path()
|
||||
|
||||
import imagecodecs
|
||||
|
||||
args = parse_args()
|
||||
if args.height % args.strip_rows != 0:
|
||||
print("height must be an integer multiple of strip-rows", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
out_dir = args.out_dir.resolve()
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
image = make_pattern(args.width, args.height, args.bit_depth, args.pattern)
|
||||
frame_bytes = []
|
||||
strip_files = []
|
||||
|
||||
for strip_index, y0 in enumerate(range(0, args.height, args.strip_rows)):
|
||||
strip = image[y0 : y0 + args.strip_rows, :]
|
||||
encoded = imagecodecs.jpegls_encode(strip)
|
||||
frame_bytes.append(encoded)
|
||||
|
||||
strip_path = out_dir / f"{args.name}.strip_{strip_index:04d}.jls"
|
||||
strip_path.write_bytes(encoded)
|
||||
strip_files.append(strip_path.name)
|
||||
|
||||
stream_path = out_dir / f"{args.name}.jls"
|
||||
pgm_path = out_dir / f"{args.name}.pgm"
|
||||
raw_path = out_dir / f"{args.name}.raw"
|
||||
json_path = out_dir / f"{args.name}.json"
|
||||
|
||||
stream_path.write_bytes(b"".join(frame_bytes))
|
||||
write_pgm(pgm_path, image, args.bit_depth)
|
||||
write_raw_big_endian(raw_path, image, args.bit_depth)
|
||||
|
||||
metadata = {
|
||||
"name": args.name,
|
||||
"width": args.width,
|
||||
"height": args.height,
|
||||
"strip_rows": args.strip_rows,
|
||||
"strip_count": len(frame_bytes),
|
||||
"bit_depth": args.bit_depth,
|
||||
"pattern": args.pattern,
|
||||
"encoder": imagecodecs.jpegls_version(),
|
||||
"note": "10/12/14-bit streams are encoded through uint16 container precision; use as tooling smoke only.",
|
||||
"stream": stream_path.name,
|
||||
"reference_pgm": pgm_path.name,
|
||||
"reference_raw_big_endian": raw_path.name,
|
||||
"strip_files": strip_files,
|
||||
"strip_bytes": [len(item) for item in frame_bytes],
|
||||
"stream_bytes": sum(len(item) for item in frame_bytes),
|
||||
}
|
||||
json_path.write_text(json.dumps(metadata, indent=2), encoding="utf-8")
|
||||
|
||||
print(f"wrote stream: {stream_path}")
|
||||
print(f"wrote reference PGM: {pgm_path}")
|
||||
print(f"wrote reference raw: {raw_path}")
|
||||
print(f"wrote metadata: {json_path}")
|
||||
print(f"strip count: {len(frame_bytes)}")
|
||||
print(f"encoder: {metadata['encoder']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
330
tools/jls_compat/reference_decode_compare.py
Normal file
330
tools/jls_compat/reference_decode_compare.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""Decode a JPEG-LS stream with reference decoders and compare results.
|
||||
|
||||
The smoke path always tries CharLS through imagecodecs. If a jpeg.org/libjpeg
|
||||
`jpeg` executable is available, the script also runs:
|
||||
|
||||
jpeg -c input.jls decoded.ppm
|
||||
|
||||
Use --require-libjpeg in mature regression runs to fail when the libjpeg
|
||||
executable is missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import BinaryIO
|
||||
|
||||
|
||||
def add_optional_dependency_path() -> None:
|
||||
"""Allow local dependency installs without changing the global Python env."""
|
||||
|
||||
dep_path = os.environ.get("JLS_COMPAT_PYDEPS")
|
||||
if dep_path:
|
||||
sys.path.insert(0, dep_path)
|
||||
|
||||
|
||||
def read_token(stream: BinaryIO) -> bytes:
|
||||
"""Read one PNM header token while skipping comments and whitespace."""
|
||||
|
||||
token = bytearray()
|
||||
|
||||
while True:
|
||||
char = stream.read(1)
|
||||
if not char:
|
||||
raise ValueError("unexpected EOF in PNM header")
|
||||
if char == b"#":
|
||||
stream.readline()
|
||||
continue
|
||||
if char.isspace():
|
||||
continue
|
||||
token.extend(char)
|
||||
break
|
||||
|
||||
while True:
|
||||
char = stream.read(1)
|
||||
if not char or char.isspace():
|
||||
break
|
||||
if char == b"#":
|
||||
stream.readline()
|
||||
break
|
||||
token.extend(char)
|
||||
|
||||
return bytes(token)
|
||||
|
||||
|
||||
def read_pnm(path: Path):
|
||||
"""Read binary PGM/PPM data into a numpy array."""
|
||||
|
||||
import numpy as np
|
||||
|
||||
with path.open("rb") as stream:
|
||||
magic = read_token(stream)
|
||||
if magic not in (b"P5", b"P6"):
|
||||
raise ValueError(f"unsupported PNM magic {magic!r}")
|
||||
|
||||
width = int(read_token(stream))
|
||||
height = int(read_token(stream))
|
||||
max_value = int(read_token(stream))
|
||||
components = 1 if magic == b"P5" else 3
|
||||
samples = width * height * components
|
||||
|
||||
if max_value <= 255:
|
||||
data = np.frombuffer(stream.read(samples), dtype=np.uint8)
|
||||
elif max_value <= 65535:
|
||||
data = np.frombuffer(stream.read(samples * 2), dtype=">u2").astype(np.uint16)
|
||||
else:
|
||||
raise ValueError(f"unsupported PNM max value {max_value}")
|
||||
|
||||
expected = samples
|
||||
if data.size != expected:
|
||||
raise ValueError(f"PNM payload size mismatch: got {data.size}, expected {expected}")
|
||||
|
||||
if components == 1:
|
||||
return data.reshape((height, width))
|
||||
return data.reshape((height, width, components))
|
||||
|
||||
|
||||
def decode_with_charls(input_path: Path):
|
||||
"""Decode through imagecodecs, which wraps CharLS."""
|
||||
|
||||
import imagecodecs
|
||||
|
||||
decoded = imagecodecs.jpegls_decode(input_path.read_bytes())
|
||||
print(f"CharLS backend: {imagecodecs.jpegls_version()}")
|
||||
print(f"CharLS decode: OK, shape={decoded.shape}, dtype={decoded.dtype}")
|
||||
return decoded
|
||||
|
||||
|
||||
def split_jpeg_frames(stream: bytes) -> list[bytes]:
|
||||
"""Split a concatenated JPEG/JPEG-LS byte stream into SOI..EOI frames."""
|
||||
|
||||
frames: list[bytes] = []
|
||||
position = 0
|
||||
|
||||
while True:
|
||||
start = stream.find(b"\xff\xd8", position)
|
||||
if start < 0:
|
||||
break
|
||||
end = stream.find(b"\xff\xd9", start + 2)
|
||||
if end < 0:
|
||||
raise ValueError("found SOI without a matching EOI")
|
||||
frames.append(stream[start : end + 2])
|
||||
position = end + 2
|
||||
|
||||
if not frames:
|
||||
raise ValueError("no SOI marker found")
|
||||
return frames
|
||||
|
||||
|
||||
def write_split_frames(input_path: Path, out_dir: Path) -> list[Path]:
|
||||
"""Write split strip frames and return their file paths."""
|
||||
|
||||
frames = split_jpeg_frames(input_path.read_bytes())
|
||||
frame_paths: list[Path] = []
|
||||
|
||||
for index, frame in enumerate(frames):
|
||||
frame_path = out_dir / f"{input_path.stem}.strip_{index:04d}.jls"
|
||||
frame_path.write_bytes(frame)
|
||||
frame_paths.append(frame_path)
|
||||
|
||||
print(f"split frames: {len(frame_paths)}")
|
||||
return frame_paths
|
||||
|
||||
|
||||
def candidate_libjpeg_paths(repo_root: Path) -> list[Path]:
|
||||
"""Return likely locations for the jpeg.org/libjpeg command line tool."""
|
||||
|
||||
env_path = os.environ.get("LIBJPEG_JPEG_EXE")
|
||||
candidates: list[Path] = []
|
||||
if env_path:
|
||||
candidates.append(Path(env_path))
|
||||
|
||||
candidates.extend(
|
||||
[
|
||||
repo_root / "third_party" / "libjpeg" / "jpeg.exe",
|
||||
repo_root / "third_party" / "libjpeg" / "jpeg",
|
||||
repo_root / "third_party" / "libjpeg" / "vs15.0" / "jpeg" / "Release" / "jpeg.exe",
|
||||
repo_root / "third_party" / "libjpeg" / "vs15.0" / "jpeg" / "x64" / "Release" / "jpeg.exe",
|
||||
repo_root / "third_party" / "libjpeg" / "vs15.0" / "jpeg" / "jpeg" / "Release" / "jpeg.exe",
|
||||
repo_root / "third_party" / "libjpeg" / "vs15.0" / "jpeg" / "jpeg" / "x64" / "Release" / "jpeg.exe",
|
||||
]
|
||||
)
|
||||
return candidates
|
||||
|
||||
|
||||
def find_libjpeg_exe(repo_root: Path, override: str | None) -> Path | None:
|
||||
"""Find a usable jpeg.org/libjpeg executable."""
|
||||
|
||||
if override:
|
||||
path = Path(override)
|
||||
return path if path.exists() else None
|
||||
|
||||
for path in candidate_libjpeg_paths(repo_root):
|
||||
if path.exists():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def decode_with_libjpeg(exe_path: Path, input_path: Path, out_dir: Path):
|
||||
"""Decode through the jpeg.org/libjpeg command line tool."""
|
||||
|
||||
output_path = out_dir / f"{input_path.stem}.libjpeg.ppm"
|
||||
command = [str(exe_path), "-c", str(input_path), str(output_path)]
|
||||
result = subprocess.run(command, check=False, text=True, capture_output=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(result.stdout)
|
||||
print(result.stderr, file=sys.stderr)
|
||||
raise RuntimeError(f"libjpeg decode failed with exit code {result.returncode}")
|
||||
|
||||
decoded = read_pnm(output_path)
|
||||
print(f"libjpeg decode: OK, output={output_path}, shape={decoded.shape}, dtype={decoded.dtype}")
|
||||
return decoded
|
||||
|
||||
|
||||
def normalize_for_compare(charls_decoded, libjpeg_decoded):
|
||||
"""Normalize common grayscale/PPM shape differences before comparison."""
|
||||
|
||||
import numpy as np
|
||||
|
||||
left = np.asarray(charls_decoded)
|
||||
right = np.asarray(libjpeg_decoded)
|
||||
|
||||
if left.ndim == 3 and left.shape[-1] == 1:
|
||||
left = left[:, :, 0]
|
||||
if right.ndim == 3 and right.shape[-1] == 1:
|
||||
right = right[:, :, 0]
|
||||
|
||||
if left.ndim == 2 and right.ndim == 3 and right.shape[-1] == 3:
|
||||
if np.array_equal(right[:, :, 0], right[:, :, 1]) and np.array_equal(right[:, :, 0], right[:, :, 2]):
|
||||
right = right[:, :, 0]
|
||||
|
||||
return left, right
|
||||
|
||||
|
||||
def compare_arrays(charls_decoded, libjpeg_decoded, max_abs_diff: int = 0) -> None:
|
||||
"""Compare decoded arrays and raise on mismatch."""
|
||||
|
||||
import numpy as np
|
||||
|
||||
left, right = normalize_for_compare(charls_decoded, libjpeg_decoded)
|
||||
if left.shape != right.shape:
|
||||
raise AssertionError(f"shape mismatch: CharLS {left.shape}, libjpeg {right.shape}")
|
||||
if left.dtype != right.dtype:
|
||||
right = right.astype(left.dtype)
|
||||
diff = np.abs(left.astype(np.int64) - right.astype(np.int64))
|
||||
if int(diff.max()) > max_abs_diff:
|
||||
raise AssertionError(
|
||||
f"pixel mismatch: max_abs_diff={int(diff.max())}, allowed={max_abs_diff}"
|
||||
)
|
||||
print("reference compare: OK")
|
||||
|
||||
|
||||
def combine_strip_frames(decoded_frames):
|
||||
"""Combine decoded strip frames vertically into one original image."""
|
||||
|
||||
import numpy as np
|
||||
|
||||
arrays = [np.asarray(frame) for frame in decoded_frames]
|
||||
if not arrays:
|
||||
raise ValueError("no decoded frames to combine")
|
||||
return np.concatenate(arrays, axis=0)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("input_jls", type=Path, help="JPEG-LS input stream")
|
||||
parser.add_argument("--out-dir", type=Path, default=Path("tools/jls_compat/out"), help="Output directory")
|
||||
parser.add_argument("--libjpeg-exe", help="Path to jpeg.org/libjpeg jpeg executable")
|
||||
parser.add_argument("--skip-charls", action="store_true", help="Do not run CharLS")
|
||||
parser.add_argument("--skip-libjpeg", action="store_true", help="Do not run jpeg.org/libjpeg")
|
||||
parser.add_argument("--require-libjpeg", action="store_true", help="Fail if jpeg.org/libjpeg executable is missing")
|
||||
parser.add_argument("--split-frames", action="store_true", help="Split a concatenated strip-frame stream first")
|
||||
parser.add_argument("--expected-frames", type=int, help="Expected number of split JPEG-LS frames")
|
||||
parser.add_argument("--reference-jls", type=Path, help="Reference JPEG-LS stream used for decoded pixel comparison")
|
||||
parser.add_argument("--reference-pgm", type=Path, help="Reference original PGM used for pixel comparison")
|
||||
parser.add_argument(
|
||||
"--max-abs-diff",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Allowed per-sample absolute difference when comparing with --reference-pgm",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
add_optional_dependency_path()
|
||||
args = parse_args()
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
input_path = args.input_jls.resolve()
|
||||
out_dir = args.out_dir.resolve()
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if args.split_frames:
|
||||
input_paths = write_split_frames(input_path, out_dir)
|
||||
else:
|
||||
input_paths = [input_path]
|
||||
|
||||
if args.expected_frames is not None and len(input_paths) != args.expected_frames:
|
||||
print(f"expected {args.expected_frames} frames, got {len(input_paths)}", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
libjpeg_exe = None
|
||||
if not args.skip_libjpeg:
|
||||
libjpeg_exe = find_libjpeg_exe(repo_root, args.libjpeg_exe)
|
||||
if libjpeg_exe is None and args.require_libjpeg:
|
||||
print("libjpeg decode: SKIP, jpeg executable not found", file=sys.stderr)
|
||||
return 2
|
||||
if libjpeg_exe is not None:
|
||||
print(f"libjpeg executable: {libjpeg_exe}")
|
||||
|
||||
charls_frames = []
|
||||
libjpeg_frames = []
|
||||
|
||||
for frame_index, frame_path in enumerate(input_paths):
|
||||
print(f"frame {frame_index}: {frame_path}")
|
||||
|
||||
charls_decoded = None
|
||||
if not args.skip_charls:
|
||||
charls_decoded = decode_with_charls(frame_path)
|
||||
charls_frames.append(charls_decoded)
|
||||
|
||||
libjpeg_decoded = None
|
||||
if not args.skip_libjpeg:
|
||||
if libjpeg_exe is None:
|
||||
print("libjpeg decode: SKIP, jpeg executable not found")
|
||||
else:
|
||||
libjpeg_decoded = decode_with_libjpeg(libjpeg_exe, frame_path, out_dir)
|
||||
libjpeg_frames.append(libjpeg_decoded)
|
||||
|
||||
if charls_decoded is not None and libjpeg_decoded is not None:
|
||||
compare_arrays(charls_decoded, libjpeg_decoded)
|
||||
|
||||
if args.reference_pgm is not None:
|
||||
reference = read_pnm(args.reference_pgm.resolve())
|
||||
if charls_frames:
|
||||
print("compare CharLS result with reference PGM")
|
||||
compare_arrays(combine_strip_frames(charls_frames), reference, args.max_abs_diff)
|
||||
if libjpeg_frames:
|
||||
print("compare libjpeg result with reference PGM")
|
||||
compare_arrays(combine_strip_frames(libjpeg_frames), reference, args.max_abs_diff)
|
||||
|
||||
if args.reference_jls is not None:
|
||||
reference_jls = decode_with_charls(args.reference_jls.resolve())
|
||||
if charls_frames:
|
||||
print("compare CharLS result with reference JLS decode")
|
||||
compare_arrays(combine_strip_frames(charls_frames), reference_jls, args.max_abs_diff)
|
||||
if libjpeg_frames:
|
||||
print("compare libjpeg result with reference JLS decode")
|
||||
compare_arrays(combine_strip_frames(libjpeg_frames), reference_jls, args.max_abs_diff)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
316
tools/jls_compat/validate_dynamic_near_stream.py
Normal file
316
tools/jls_compat/validate_dynamic_near_stream.py
Normal file
@@ -0,0 +1,316 @@
|
||||
"""Validate a concatenated strip-frame JPEG-LS stream against a reference PGM.
|
||||
|
||||
This helper is for the project's dynamic-NEAR multi-frame stream format.
|
||||
It splits the RTL output into strip frames, parses each strip's JPEG-LS NEAR
|
||||
value from the SOS segment, decodes every strip with CharLS, compares the
|
||||
decoded strip against the corresponding source-image rows with that strip's
|
||||
actual NEAR bound, and can optionally emit a strip-by-strip CharLS reference
|
||||
stream using the same NEAR schedule.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
JPEG_SOI = b"\xff\xd8"
|
||||
JPEG_EOI = b"\xff\xd9"
|
||||
JPEG_SOS = b"\xff\xda"
|
||||
JPEG_LS_INTERCHANGE_PREFIX = b"\xff\xd8\xff\xf7"
|
||||
|
||||
|
||||
def add_optional_dependency_path() -> None:
|
||||
dep_path = os.environ.get("JLS_COMPAT_PYDEPS")
|
||||
if dep_path:
|
||||
sys.path.insert(0, dep_path)
|
||||
return
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
bundled_dep = repo_root / "tools" / "jls_compat" / ".deps"
|
||||
sys.path.insert(0, str(bundled_dep))
|
||||
|
||||
|
||||
def read_non_comment_line(handle) -> bytes:
|
||||
while True:
|
||||
line = handle.readline()
|
||||
if not line:
|
||||
raise ValueError("unexpected EOF while reading PGM header")
|
||||
if not line.startswith(b"#"):
|
||||
return line
|
||||
|
||||
|
||||
def read_pgm(path: Path):
|
||||
import numpy as np
|
||||
|
||||
with path.open("rb") as handle:
|
||||
magic = read_non_comment_line(handle).strip()
|
||||
if magic != b"P5":
|
||||
raise ValueError(f"{path} is not a binary PGM file")
|
||||
|
||||
dims = read_non_comment_line(handle).strip().split()
|
||||
if len(dims) != 2:
|
||||
raise ValueError(f"{path} has invalid dimension line")
|
||||
|
||||
width = int(dims[0])
|
||||
height = int(dims[1])
|
||||
max_value = int(read_non_comment_line(handle).strip())
|
||||
payload = handle.read()
|
||||
|
||||
if max_value <= 255:
|
||||
expected_size = width * height
|
||||
if len(payload) != expected_size:
|
||||
raise ValueError(f"{path} payload {len(payload)} != {expected_size}")
|
||||
image = np.frombuffer(payload, dtype=np.uint8).reshape((height, width))
|
||||
else:
|
||||
expected_size = width * height * 2
|
||||
if len(payload) != expected_size:
|
||||
raise ValueError(f"{path} payload {len(payload)} != {expected_size}")
|
||||
image = np.frombuffer(payload, dtype=">u2").reshape((height, width)).astype(np.uint16)
|
||||
|
||||
return image, width, height, max_value
|
||||
|
||||
|
||||
def split_jpeg_frames(stream: bytes) -> list[bytes]:
|
||||
frames: list[bytes] = []
|
||||
position = 0
|
||||
|
||||
while True:
|
||||
start = stream.find(JPEG_SOI, position)
|
||||
if start < 0:
|
||||
break
|
||||
end = stream.find(JPEG_EOI, start + 2)
|
||||
if end < 0:
|
||||
raise ValueError("found SOI without a matching EOI")
|
||||
frames.append(stream[start : end + 2])
|
||||
position = end + 2
|
||||
|
||||
if not frames:
|
||||
raise ValueError("no SOI marker found")
|
||||
return frames
|
||||
|
||||
|
||||
def parse_strip_near(frame: bytes) -> int:
|
||||
sos_offset = frame.find(JPEG_SOS)
|
||||
if sos_offset < 0:
|
||||
raise ValueError("SOS marker not found in strip frame")
|
||||
|
||||
if sos_offset + 4 > len(frame):
|
||||
raise ValueError("SOS marker truncated")
|
||||
|
||||
segment_length = (int(frame[sos_offset + 2]) << 8) | int(frame[sos_offset + 3])
|
||||
segment_end = sos_offset + 2 + segment_length
|
||||
if segment_end > len(frame):
|
||||
raise ValueError("SOS segment extends beyond frame")
|
||||
|
||||
segment = frame[sos_offset + 4 : segment_end]
|
||||
if not segment:
|
||||
raise ValueError("SOS segment payload is empty")
|
||||
|
||||
component_count = int(segment[0])
|
||||
near_index = 1 + (2 * component_count)
|
||||
if near_index >= len(segment):
|
||||
raise ValueError("SOS segment is too short to contain NEAR")
|
||||
|
||||
return int(segment[near_index])
|
||||
|
||||
|
||||
def normalize_to_interchange_codestream(encoded: bytes) -> tuple[bytes, bool]:
|
||||
if encoded.startswith(JPEG_LS_INTERCHANGE_PREFIX):
|
||||
if not encoded.endswith(JPEG_EOI):
|
||||
raise ValueError("JPEG-LS codestream is missing EOI")
|
||||
return encoded, False
|
||||
|
||||
spiff_payload_offset = encoded.find(JPEG_LS_INTERCHANGE_PREFIX, len(JPEG_SOI))
|
||||
if spiff_payload_offset < 0:
|
||||
raise ValueError("failed to locate embedded JPEG-LS interchange stream")
|
||||
|
||||
normalized = encoded[spiff_payload_offset:]
|
||||
if not normalized.endswith(JPEG_EOI):
|
||||
raise ValueError("embedded JPEG-LS codestream is missing EOI")
|
||||
return normalized, True
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("input_jls", type=Path, help="RTL concatenated JPEG-LS stream")
|
||||
parser.add_argument("--reference-pgm", type=Path, required=True, help="Source PGM image")
|
||||
parser.add_argument(
|
||||
"--configured-compression-ratio",
|
||||
type=int,
|
||||
required=True,
|
||||
choices=[1, 2, 4, 8],
|
||||
help="Human-readable target compression ratio denominator: 1/2/4/8",
|
||||
)
|
||||
parser.add_argument("--expected-frames", type=int, help="Expected strip frame count")
|
||||
parser.add_argument(
|
||||
"--output-reference-jls",
|
||||
type=Path,
|
||||
help="Optional CharLS reference codestream built with the same strip NEAR values",
|
||||
)
|
||||
parser.add_argument("--summary-json", type=Path, help="Optional JSON summary output path")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
add_optional_dependency_path()
|
||||
|
||||
import imagecodecs
|
||||
import numpy as np
|
||||
|
||||
args = parse_args()
|
||||
input_path = args.input_jls.resolve()
|
||||
reference_pgm_path = args.reference_pgm.resolve()
|
||||
stream_bytes = input_path.read_bytes()
|
||||
frames = split_jpeg_frames(stream_bytes)
|
||||
|
||||
if args.expected_frames is not None and len(frames) != args.expected_frames:
|
||||
print(f"expected {args.expected_frames} frames, got {len(frames)}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
reference, width, height, max_value = read_pgm(reference_pgm_path)
|
||||
bit_depth = max(1, int(max_value).bit_length())
|
||||
|
||||
strip_rows_consumed = 0
|
||||
overall_max_abs_diff = 0
|
||||
total_reference_bytes = bytearray()
|
||||
strip_reports = []
|
||||
|
||||
for strip_index, frame in enumerate(frames):
|
||||
strip_near = parse_strip_near(frame)
|
||||
decoded = imagecodecs.jpegls_decode(frame)
|
||||
if decoded.ndim != 2:
|
||||
raise ValueError(f"frame {strip_index} is not grayscale: shape={decoded.shape}")
|
||||
|
||||
strip_height = int(decoded.shape[0])
|
||||
strip_width = int(decoded.shape[1])
|
||||
if strip_width != width:
|
||||
raise ValueError(
|
||||
f"frame {strip_index} width {strip_width} != reference width {width}"
|
||||
)
|
||||
|
||||
row_end = strip_rows_consumed + strip_height
|
||||
if row_end > height:
|
||||
raise ValueError(
|
||||
f"frame {strip_index} rows exceed reference height: end={row_end}, height={height}"
|
||||
)
|
||||
|
||||
reference_strip = np.asarray(reference[strip_rows_consumed:row_end, :])
|
||||
decoded_strip = np.asarray(decoded)
|
||||
if decoded_strip.dtype != reference_strip.dtype:
|
||||
decoded_strip = decoded_strip.astype(reference_strip.dtype)
|
||||
|
||||
diff = np.abs(
|
||||
decoded_strip.astype(np.int64) - reference_strip.astype(np.int64)
|
||||
)
|
||||
strip_max_abs_diff = int(diff.max()) if diff.size != 0 else 0
|
||||
overall_max_abs_diff = max(overall_max_abs_diff, strip_max_abs_diff)
|
||||
|
||||
if strip_max_abs_diff > strip_near:
|
||||
print(
|
||||
f"strip {strip_index} max_abs_diff={strip_max_abs_diff} exceeds NEAR={strip_near}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 3
|
||||
|
||||
normalized_reference = None
|
||||
if args.output_reference_jls is not None:
|
||||
out_capacity = int(reference_strip.nbytes * 2 + 65536)
|
||||
encoded_reference = imagecodecs.jpegls_encode(
|
||||
reference_strip, level=strip_near, out=out_capacity
|
||||
)
|
||||
normalized_reference, _ = normalize_to_interchange_codestream(encoded_reference)
|
||||
total_reference_bytes.extend(normalized_reference)
|
||||
|
||||
strip_reports.append(
|
||||
{
|
||||
"index": strip_index,
|
||||
"rows": strip_height,
|
||||
"near": strip_near,
|
||||
"rtl_bytes": len(frame),
|
||||
"charls_reference_bytes": len(normalized_reference)
|
||||
if normalized_reference is not None
|
||||
else None,
|
||||
"max_abs_diff": strip_max_abs_diff,
|
||||
}
|
||||
)
|
||||
strip_rows_consumed = row_end
|
||||
|
||||
if strip_rows_consumed != height:
|
||||
print(
|
||||
f"decoded strip rows {strip_rows_consumed} do not cover reference height {height}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 4
|
||||
|
||||
if overall_max_abs_diff > 31:
|
||||
print(
|
||||
f"overall max_abs_diff={overall_max_abs_diff} exceeds global limit 31",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 5
|
||||
|
||||
raw_bytes = int(reference.size * reference.itemsize)
|
||||
raw_bits = raw_bytes * 8
|
||||
target_bits = raw_bits // args.configured_compression_ratio
|
||||
actual_bits = len(stream_bytes) * 8
|
||||
actual_ratio = float(raw_bytes) / float(len(stream_bytes))
|
||||
target_error_pct = 0.0
|
||||
target_excess_pct = 0.0
|
||||
target_ratio_fail = False
|
||||
if args.configured_compression_ratio != 1:
|
||||
target_error_pct = (actual_bits - target_bits) * 100.0 / float(target_bits)
|
||||
if actual_bits > target_bits:
|
||||
target_excess_pct = target_error_pct
|
||||
if target_excess_pct > 10.0:
|
||||
target_ratio_fail = True
|
||||
|
||||
if args.output_reference_jls is not None:
|
||||
output_reference_path = args.output_reference_jls.resolve()
|
||||
output_reference_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_reference_path.write_bytes(bytes(total_reference_bytes))
|
||||
|
||||
summary = {
|
||||
"rtl_stream": str(input_path),
|
||||
"reference_pgm": str(reference_pgm_path),
|
||||
"configured_compression_ratio": args.configured_compression_ratio,
|
||||
"frame_count": len(frames),
|
||||
"width": width,
|
||||
"height": height,
|
||||
"bit_depth": bit_depth,
|
||||
"raw_bytes": raw_bytes,
|
||||
"rtl_bytes": len(stream_bytes),
|
||||
"actual_ratio": actual_ratio,
|
||||
"target_bits": target_bits,
|
||||
"actual_bits": actual_bits,
|
||||
"target_error_pct": target_error_pct,
|
||||
"target_excess_pct": target_excess_pct,
|
||||
"overall_max_abs_diff": overall_max_abs_diff,
|
||||
"strips": strip_reports,
|
||||
}
|
||||
|
||||
if args.output_reference_jls is not None:
|
||||
summary["generated_reference_jls"] = str(args.output_reference_jls.resolve())
|
||||
|
||||
if args.summary_json is not None:
|
||||
summary_path = args.summary_json.resolve()
|
||||
summary_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
summary_path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
|
||||
|
||||
print(json.dumps(summary, indent=2))
|
||||
if target_ratio_fail:
|
||||
print(
|
||||
"target ratio miss exceeds 10%: "
|
||||
f"actual_bits={actual_bits}, target_bits={target_bits}, "
|
||||
f"excess_pct={target_excess_pct:.3f}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 6
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
15
tools/jls_decoder/JlsDecoder.csproj
Normal file
15
tools/jls_decoder/JlsDecoder.csproj
Normal file
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CharLS.Native" Version="4.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
296
tools/jls_decoder/JpegLsStripStreamDecoder.cs
Normal file
296
tools/jls_decoder/JpegLsStripStreamDecoder.cs
Normal file
@@ -0,0 +1,296 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Drawing.Imaging;
|
||||
using CharLS.Native;
|
||||
|
||||
namespace JlsDecoder;
|
||||
|
||||
public sealed record StripFrameViewModel(
|
||||
int FrameIndex,
|
||||
int Width,
|
||||
int Height,
|
||||
int BitsPerSample,
|
||||
int NearLossless,
|
||||
int EncodedBytes,
|
||||
int DecodedBytes);
|
||||
|
||||
public sealed class JpegLsCompositeImage
|
||||
{
|
||||
public required string SourcePath { get; init; }
|
||||
|
||||
public required int Width { get; init; }
|
||||
|
||||
public required int Height { get; init; }
|
||||
|
||||
public required int BitsPerSample { get; init; }
|
||||
|
||||
public required ushort[] Samples { get; init; }
|
||||
|
||||
public required IReadOnlyList<StripFrameViewModel> Strips { get; init; }
|
||||
|
||||
public required ushort MinimumSample { get; init; }
|
||||
|
||||
public required ushort MaximumSample { get; init; }
|
||||
|
||||
public int FrameCount => Strips.Count;
|
||||
|
||||
public int MaxNear => Strips.Count == 0 ? 0 : Strips.Max(item => item.NearLossless);
|
||||
|
||||
public Bitmap CreatePreviewBitmap(bool autoStretch)
|
||||
{
|
||||
int maxDisplayValue = (1 << Math.Min(BitsPerSample, 16)) - 1;
|
||||
int stretchMin = autoStretch ? MinimumSample : 0;
|
||||
int stretchMax = autoStretch ? MaximumSample : maxDisplayValue;
|
||||
if (stretchMax <= stretchMin)
|
||||
{
|
||||
stretchMax = stretchMin + 1;
|
||||
}
|
||||
|
||||
Bitmap bitmap = new(Width, Height, PixelFormat.Format24bppRgb);
|
||||
Rectangle rect = new(0, 0, Width, Height);
|
||||
BitmapData data = bitmap.LockBits(rect, ImageLockMode.WriteOnly, bitmap.PixelFormat);
|
||||
|
||||
try
|
||||
{
|
||||
byte[] rgb = new byte[data.Stride * Height];
|
||||
int srcIndex = 0;
|
||||
|
||||
for (int y = 0; y < Height; y++)
|
||||
{
|
||||
int rowOffset = y * data.Stride;
|
||||
for (int x = 0; x < Width; x++)
|
||||
{
|
||||
int sample = Samples[srcIndex++];
|
||||
int gray = (sample - stretchMin) * 255 / (stretchMax - stretchMin);
|
||||
if (gray < 0)
|
||||
{
|
||||
gray = 0;
|
||||
}
|
||||
else if (gray > 255)
|
||||
{
|
||||
gray = 255;
|
||||
}
|
||||
|
||||
int pixelOffset = rowOffset + (x * 3);
|
||||
byte grayByte = (byte)gray;
|
||||
rgb[pixelOffset] = grayByte;
|
||||
rgb[pixelOffset + 1] = grayByte;
|
||||
rgb[pixelOffset + 2] = grayByte;
|
||||
}
|
||||
}
|
||||
|
||||
System.Runtime.InteropServices.Marshal.Copy(rgb, 0, data.Scan0, rgb.Length);
|
||||
}
|
||||
finally
|
||||
{
|
||||
bitmap.UnlockBits(data);
|
||||
}
|
||||
|
||||
return bitmap;
|
||||
}
|
||||
}
|
||||
|
||||
public static class JpegLsStripStreamDecoder
|
||||
{
|
||||
private static readonly byte[] SoiMarker = [0xFF, 0xD8];
|
||||
private static readonly byte[] EoiMarker = [0xFF, 0xD9];
|
||||
|
||||
public static JpegLsCompositeImage DecodeCompositeImage(string path)
|
||||
{
|
||||
byte[] streamBytes = File.ReadAllBytes(path);
|
||||
List<byte[]> frames = SplitFrames(streamBytes);
|
||||
if (frames.Count == 0)
|
||||
{
|
||||
throw new InvalidDataException("No JPEG-LS frame was found in the input stream.");
|
||||
}
|
||||
|
||||
List<StripFrameViewModel> stripRows = [];
|
||||
List<ushort[]> decodedStrips = [];
|
||||
|
||||
int expectedWidth = 0;
|
||||
int expectedBitsPerSample = 0;
|
||||
int totalHeight = 0;
|
||||
ushort minSample = ushort.MaxValue;
|
||||
ushort maxSample = ushort.MinValue;
|
||||
|
||||
for (int frameIndex = 0; frameIndex < frames.Count; frameIndex++)
|
||||
{
|
||||
byte[] frame = frames[frameIndex];
|
||||
using JpegLSDecoder decoder = new(frame, true);
|
||||
|
||||
FrameInfo frameInfo = decoder.FrameInfo;
|
||||
if (frameInfo.ComponentCount != 1)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Frame {frameIndex} has component_count={frameInfo.ComponentCount}; only grayscale single-component streams are supported.");
|
||||
}
|
||||
|
||||
if (frameIndex == 0)
|
||||
{
|
||||
expectedWidth = frameInfo.Width;
|
||||
expectedBitsPerSample = frameInfo.BitsPerSample;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (frameInfo.Width != expectedWidth)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Frame {frameIndex} width {frameInfo.Width} does not match the first strip width {expectedWidth}.");
|
||||
}
|
||||
|
||||
if (frameInfo.BitsPerSample != expectedBitsPerSample)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Frame {frameIndex} bits_per_sample {frameInfo.BitsPerSample} does not match the first strip bit depth {expectedBitsPerSample}.");
|
||||
}
|
||||
}
|
||||
|
||||
int decodedBytes = decoder.GetDestinationSize(JpegLSDecoder.AutoCalculateStride);
|
||||
byte[] decodedBuffer = decoder.Decode(JpegLSDecoder.AutoCalculateStride);
|
||||
ushort[] stripSamples = UnpackSamples(decodedBuffer, frameInfo.Width, frameInfo.Height, frameInfo.BitsPerSample);
|
||||
|
||||
foreach (ushort sample in stripSamples)
|
||||
{
|
||||
if (sample < minSample)
|
||||
{
|
||||
minSample = sample;
|
||||
}
|
||||
if (sample > maxSample)
|
||||
{
|
||||
maxSample = sample;
|
||||
}
|
||||
}
|
||||
|
||||
totalHeight += frameInfo.Height;
|
||||
decodedStrips.Add(stripSamples);
|
||||
stripRows.Add(new StripFrameViewModel(
|
||||
frameIndex,
|
||||
frameInfo.Width,
|
||||
frameInfo.Height,
|
||||
frameInfo.BitsPerSample,
|
||||
decoder.NearLossless,
|
||||
frame.Length,
|
||||
decodedBytes));
|
||||
}
|
||||
|
||||
ushort[] composite = new ushort[expectedWidth * totalHeight];
|
||||
int destRow = 0;
|
||||
for (int stripIndex = 0; stripIndex < decodedStrips.Count; stripIndex++)
|
||||
{
|
||||
ushort[] stripSamples = decodedStrips[stripIndex];
|
||||
int stripHeight = stripRows[stripIndex].Height;
|
||||
int stripRowSamples = stripRows[stripIndex].Width;
|
||||
|
||||
for (int row = 0; row < stripHeight; row++)
|
||||
{
|
||||
Array.Copy(
|
||||
stripSamples,
|
||||
row * stripRowSamples,
|
||||
composite,
|
||||
destRow * expectedWidth,
|
||||
expectedWidth);
|
||||
destRow++;
|
||||
}
|
||||
}
|
||||
|
||||
if (minSample == ushort.MaxValue)
|
||||
{
|
||||
minSample = 0;
|
||||
}
|
||||
|
||||
return new JpegLsCompositeImage
|
||||
{
|
||||
SourcePath = path,
|
||||
Width = expectedWidth,
|
||||
Height = totalHeight,
|
||||
BitsPerSample = expectedBitsPerSample,
|
||||
Samples = composite,
|
||||
Strips = stripRows,
|
||||
MinimumSample = minSample,
|
||||
MaximumSample = maxSample
|
||||
};
|
||||
}
|
||||
|
||||
private static List<byte[]> SplitFrames(byte[] streamBytes)
|
||||
{
|
||||
List<byte[]> frames = [];
|
||||
int position = 0;
|
||||
|
||||
while (position < streamBytes.Length)
|
||||
{
|
||||
int soi = IndexOf(streamBytes, SoiMarker, position);
|
||||
if (soi < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
int eoi = IndexOf(streamBytes, EoiMarker, soi + 2);
|
||||
if (eoi < 0)
|
||||
{
|
||||
throw new InvalidDataException($"Found SOI at offset 0x{soi:X}, but no matching EOI marker was found.");
|
||||
}
|
||||
|
||||
int frameLength = (eoi - soi) + 2;
|
||||
byte[] frame = new byte[frameLength];
|
||||
Buffer.BlockCopy(streamBytes, soi, frame, 0, frameLength);
|
||||
frames.Add(frame);
|
||||
position = eoi + 2;
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
private static int IndexOf(byte[] haystack, byte[] needle, int startIndex)
|
||||
{
|
||||
for (int index = startIndex; index <= haystack.Length - needle.Length; index++)
|
||||
{
|
||||
bool match = true;
|
||||
for (int inner = 0; inner < needle.Length; inner++)
|
||||
{
|
||||
if (haystack[index + inner] != needle[inner])
|
||||
{
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (match)
|
||||
{
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static ushort[] UnpackSamples(byte[] decodedBuffer, int width, int height, int bitsPerSample)
|
||||
{
|
||||
int pixelCount = width * height;
|
||||
ushort[] samples = new ushort[pixelCount];
|
||||
|
||||
if (bitsPerSample <= 8)
|
||||
{
|
||||
for (int index = 0; index < pixelCount; index++)
|
||||
{
|
||||
samples[index] = decodedBuffer[index];
|
||||
}
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
int requiredBytes = pixelCount * 2;
|
||||
if (decodedBuffer.Length < requiredBytes)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Decoded byte count {decodedBuffer.Length} is smaller than the required 16-bit image payload {requiredBytes}.");
|
||||
}
|
||||
|
||||
int bufferOffset = 0;
|
||||
for (int index = 0; index < pixelCount; index++)
|
||||
{
|
||||
samples[index] = BinaryPrimitives.ReadUInt16LittleEndian(decodedBuffer.AsSpan(bufferOffset, 2));
|
||||
bufferOffset += 2;
|
||||
}
|
||||
|
||||
return samples;
|
||||
}
|
||||
}
|
||||
290
tools/jls_decoder/MainForm.cs
Normal file
290
tools/jls_decoder/MainForm.cs
Normal file
@@ -0,0 +1,290 @@
|
||||
using System.ComponentModel;
|
||||
using System.Drawing.Imaging;
|
||||
|
||||
namespace JlsDecoder;
|
||||
|
||||
internal sealed class MainForm : Form
|
||||
{
|
||||
private readonly string? initialPath;
|
||||
|
||||
private readonly Button openButton = new() { Text = "Open" };
|
||||
private readonly Button reloadButton = new() { Text = "Reload", Enabled = false };
|
||||
private readonly Button savePreviewButton = new() { Text = "Save Preview", Enabled = false };
|
||||
private readonly CheckBox autoStretchCheckBox = new() { Text = "Auto Stretch", Checked = true };
|
||||
private readonly Label summaryLabel = new() { AutoEllipsis = true };
|
||||
private readonly TextBox detailsTextBox = new()
|
||||
{
|
||||
Multiline = true,
|
||||
ReadOnly = true,
|
||||
ScrollBars = ScrollBars.Vertical,
|
||||
WordWrap = false,
|
||||
Font = new Font("Consolas", 9F, FontStyle.Regular, GraphicsUnit.Point)
|
||||
};
|
||||
private readonly DataGridView stripGrid = new()
|
||||
{
|
||||
ReadOnly = true,
|
||||
AllowUserToAddRows = false,
|
||||
AllowUserToDeleteRows = false,
|
||||
AllowUserToResizeRows = false,
|
||||
AutoGenerateColumns = true,
|
||||
Dock = DockStyle.Fill,
|
||||
RowHeadersVisible = false,
|
||||
SelectionMode = DataGridViewSelectionMode.FullRowSelect
|
||||
};
|
||||
private readonly PictureBox pictureBox = new()
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
BackColor = Color.Black,
|
||||
SizeMode = PictureBoxSizeMode.Zoom
|
||||
};
|
||||
private readonly StatusStrip statusStrip = new();
|
||||
private readonly ToolStripStatusLabel statusText = new() { Text = "Ready" };
|
||||
|
||||
private string? currentPath;
|
||||
private JpegLsCompositeImage? currentComposite;
|
||||
private Bitmap? currentBitmap;
|
||||
|
||||
public MainForm(string? initialPath)
|
||||
{
|
||||
this.initialPath = initialPath;
|
||||
|
||||
Text = "JLS Strip Decoder";
|
||||
MinimumSize = new Size(1100, 700);
|
||||
Width = 1400;
|
||||
Height = 900;
|
||||
|
||||
BuildUi();
|
||||
BindEvents();
|
||||
}
|
||||
|
||||
protected override async void OnShown(EventArgs e)
|
||||
{
|
||||
base.OnShown(e);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(initialPath) && File.Exists(initialPath))
|
||||
{
|
||||
await LoadImageAsync(initialPath);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnFormClosed(FormClosedEventArgs e)
|
||||
{
|
||||
base.OnFormClosed(e);
|
||||
currentBitmap?.Dispose();
|
||||
}
|
||||
|
||||
private void BuildUi()
|
||||
{
|
||||
statusStrip.Items.Add(statusText);
|
||||
Controls.Add(statusStrip);
|
||||
|
||||
FlowLayoutPanel topBar = new()
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
Height = 40,
|
||||
Padding = new Padding(8, 6, 8, 6),
|
||||
AutoSize = false
|
||||
};
|
||||
|
||||
topBar.Controls.Add(openButton);
|
||||
topBar.Controls.Add(reloadButton);
|
||||
topBar.Controls.Add(savePreviewButton);
|
||||
topBar.Controls.Add(autoStretchCheckBox);
|
||||
Controls.Add(topBar);
|
||||
|
||||
summaryLabel.Dock = DockStyle.Top;
|
||||
summaryLabel.Height = 28;
|
||||
summaryLabel.Padding = new Padding(10, 6, 10, 0);
|
||||
Controls.Add(summaryLabel);
|
||||
|
||||
SplitContainer mainSplit = new()
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
Orientation = Orientation.Vertical,
|
||||
SplitterDistance = 420
|
||||
};
|
||||
|
||||
SplitContainer leftSplit = new()
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
Orientation = Orientation.Horizontal,
|
||||
SplitterDistance = 280
|
||||
};
|
||||
|
||||
leftSplit.Panel1.Controls.Add(stripGrid);
|
||||
leftSplit.Panel2.Controls.Add(detailsTextBox);
|
||||
mainSplit.Panel1.Controls.Add(leftSplit);
|
||||
|
||||
Panel imagePanel = new() { Dock = DockStyle.Fill, Padding = new Padding(8) };
|
||||
imagePanel.Controls.Add(pictureBox);
|
||||
mainSplit.Panel2.Controls.Add(imagePanel);
|
||||
|
||||
Controls.Add(mainSplit);
|
||||
}
|
||||
|
||||
private void BindEvents()
|
||||
{
|
||||
openButton.Click += async (_, _) => await SelectAndOpenAsync();
|
||||
reloadButton.Click += async (_, _) =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(currentPath))
|
||||
{
|
||||
await LoadImageAsync(currentPath);
|
||||
}
|
||||
};
|
||||
savePreviewButton.Click += (_, _) => SavePreview();
|
||||
autoStretchCheckBox.CheckedChanged += (_, _) => RefreshPreview();
|
||||
|
||||
AllowDrop = true;
|
||||
DragEnter += OnDragEnter;
|
||||
DragDrop += async (_, e) =>
|
||||
{
|
||||
if (e.Data?.GetData(DataFormats.FileDrop) is string[] files && files.Length > 0)
|
||||
{
|
||||
await LoadImageAsync(files[0]);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async Task SelectAndOpenAsync()
|
||||
{
|
||||
using OpenFileDialog dialog = new()
|
||||
{
|
||||
Title = "Open JPEG-LS Strip Stream",
|
||||
Filter = "JPEG-LS streams|*.charlsjls;*.rtljls;*.jls|All files|*.*"
|
||||
};
|
||||
|
||||
if (dialog.ShowDialog(this) == DialogResult.OK)
|
||||
{
|
||||
await LoadImageAsync(dialog.FileName);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadImageAsync(string path)
|
||||
{
|
||||
SetBusyState(true, $"Loading {Path.GetFileName(path)} ...");
|
||||
|
||||
try
|
||||
{
|
||||
JpegLsCompositeImage composite = await Task.Run(() => JpegLsStripStreamDecoder.DecodeCompositeImage(path));
|
||||
|
||||
currentPath = path;
|
||||
currentComposite = composite;
|
||||
reloadButton.Enabled = true;
|
||||
savePreviewButton.Enabled = true;
|
||||
|
||||
summaryLabel.Text =
|
||||
$"{Path.GetFileName(path)} " +
|
||||
$"{composite.Width}x{composite.Height} " +
|
||||
$"{composite.BitsPerSample} bit " +
|
||||
$"frames={composite.FrameCount} " +
|
||||
$"maxNear={composite.MaxNear} " +
|
||||
$"min={composite.MinimumSample} " +
|
||||
$"max={composite.MaximumSample}";
|
||||
|
||||
stripGrid.DataSource = new BindingList<StripFrameViewModel>(composite.Strips.ToList());
|
||||
detailsTextBox.Text = BuildDetails(composite);
|
||||
RefreshPreview();
|
||||
|
||||
SetBusyState(false, $"Loaded {Path.GetFileName(path)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
currentComposite = null;
|
||||
currentBitmap?.Dispose();
|
||||
currentBitmap = null;
|
||||
pictureBox.Image = null;
|
||||
summaryLabel.Text = string.Empty;
|
||||
detailsTextBox.Text = ex.ToString();
|
||||
stripGrid.DataSource = null;
|
||||
reloadButton.Enabled = !string.IsNullOrWhiteSpace(currentPath);
|
||||
savePreviewButton.Enabled = false;
|
||||
SetBusyState(false, "Load failed");
|
||||
|
||||
MessageBox.Show(this, ex.Message, "Load failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshPreview()
|
||||
{
|
||||
if (currentComposite is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
currentBitmap?.Dispose();
|
||||
currentBitmap = currentComposite.CreatePreviewBitmap(autoStretchCheckBox.Checked);
|
||||
pictureBox.Image = currentBitmap;
|
||||
}
|
||||
|
||||
private void SavePreview()
|
||||
{
|
||||
if (currentBitmap is null || string.IsNullOrWhiteSpace(currentPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string defaultName = Path.GetFileNameWithoutExtension(currentPath) + ".preview.png";
|
||||
using SaveFileDialog dialog = new()
|
||||
{
|
||||
Title = "Save Preview",
|
||||
Filter = "PNG image|*.png",
|
||||
FileName = defaultName
|
||||
};
|
||||
|
||||
if (dialog.ShowDialog(this) == DialogResult.OK)
|
||||
{
|
||||
currentBitmap.Save(dialog.FileName, ImageFormat.Png);
|
||||
statusText.Text = $"Saved preview: {dialog.FileName}";
|
||||
}
|
||||
}
|
||||
|
||||
private void SetBusyState(bool busy, string text)
|
||||
{
|
||||
UseWaitCursor = busy;
|
||||
openButton.Enabled = !busy;
|
||||
reloadButton.Enabled = !busy && !string.IsNullOrWhiteSpace(currentPath);
|
||||
savePreviewButton.Enabled = !busy && currentComposite is not null;
|
||||
autoStretchCheckBox.Enabled = !busy;
|
||||
statusText.Text = text;
|
||||
}
|
||||
|
||||
private void OnDragEnter(object? sender, DragEventArgs e)
|
||||
{
|
||||
if (e.Data?.GetData(DataFormats.FileDrop) is string[] files && files.Length > 0)
|
||||
{
|
||||
e.Effect = DragDropEffects.Copy;
|
||||
return;
|
||||
}
|
||||
|
||||
e.Effect = DragDropEffects.None;
|
||||
}
|
||||
|
||||
private static string BuildDetails(JpegLsCompositeImage composite)
|
||||
{
|
||||
List<string> lines =
|
||||
[
|
||||
$"Source : {composite.SourcePath}",
|
||||
$"Composite : {composite.Width} x {composite.Height}",
|
||||
$"Bit depth : {composite.BitsPerSample}",
|
||||
$"Frame count : {composite.FrameCount}",
|
||||
$"Max NEAR : {composite.MaxNear}",
|
||||
$"Value range : {composite.MinimumSample} .. {composite.MaximumSample}",
|
||||
string.Empty,
|
||||
"Per-strip summary:"
|
||||
];
|
||||
|
||||
foreach (StripFrameViewModel strip in composite.Strips)
|
||||
{
|
||||
lines.Add(
|
||||
$" strip[{strip.FrameIndex:D2}] " +
|
||||
$"size={strip.Width}x{strip.Height} " +
|
||||
$"bits={strip.BitsPerSample} " +
|
||||
$"near={strip.NearLossless} " +
|
||||
$"enc={strip.EncodedBytes}B " +
|
||||
$"dec={strip.DecodedBytes}B");
|
||||
}
|
||||
|
||||
return string.Join(Environment.NewLine, lines);
|
||||
}
|
||||
}
|
||||
53
tools/jls_decoder/Program.cs
Normal file
53
tools/jls_decoder/Program.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
namespace JlsDecoder;
|
||||
|
||||
static class Program
|
||||
{
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
string[] args = Environment.GetCommandLineArgs();
|
||||
if (args.Length > 2 && string.Equals(args[1], "--probe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
RunProbe(args[2]);
|
||||
return;
|
||||
}
|
||||
if (args.Length > 3 && string.Equals(args[1], "--export-preview", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ExportPreview(args[2], args[3]);
|
||||
return;
|
||||
}
|
||||
|
||||
ApplicationConfiguration.Initialize();
|
||||
string? initialPath = null;
|
||||
if (args.Length > 1)
|
||||
{
|
||||
initialPath = args[1];
|
||||
}
|
||||
|
||||
Application.Run(new MainForm(initialPath));
|
||||
}
|
||||
|
||||
private static void RunProbe(string path)
|
||||
{
|
||||
JpegLsCompositeImage composite = JpegLsStripStreamDecoder.DecodeCompositeImage(path);
|
||||
Console.WriteLine(
|
||||
$"OK width={composite.Width} height={composite.Height} bits={composite.BitsPerSample} " +
|
||||
$"frames={composite.FrameCount} maxNear={composite.MaxNear} " +
|
||||
$"min={composite.MinimumSample} max={composite.MaximumSample}");
|
||||
|
||||
foreach (StripFrameViewModel strip in composite.Strips)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"strip[{strip.FrameIndex}] size={strip.Width}x{strip.Height} " +
|
||||
$"near={strip.NearLossless} enc={strip.EncodedBytes} dec={strip.DecodedBytes}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExportPreview(string inputPath, string outputPath)
|
||||
{
|
||||
JpegLsCompositeImage composite = JpegLsStripStreamDecoder.DecodeCompositeImage(inputPath);
|
||||
using var bitmap = composite.CreatePreviewBitmap(autoStretch: true);
|
||||
bitmap.Save(outputPath);
|
||||
Console.WriteLine($"Saved preview: {outputPath}");
|
||||
}
|
||||
}
|
||||
52
tools/jls_decoder/README.md
Normal file
52
tools/jls_decoder/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# JLS Decoder
|
||||
|
||||
Small WinForms viewer for the project's concatenated JPEG-LS strip streams.
|
||||
|
||||
Supported input types:
|
||||
|
||||
- `*.charlsjls`
|
||||
- `*.rtljls`
|
||||
- `*.jls`
|
||||
|
||||
What it does:
|
||||
|
||||
1. Split one file into multiple standalone `SOI..EOI` JPEG-LS strip frames.
|
||||
2. Decode each strip with the official `CharLS.Native` package.
|
||||
3. Recombine the strips vertically into one grayscale image.
|
||||
4. Display the reconstructed full image in one window.
|
||||
|
||||
This avoids the common viewer problem where only the first `6144x16` strip is
|
||||
shown instead of the full `6144x256` image.
|
||||
|
||||
## Build
|
||||
|
||||
```powershell
|
||||
dotnet build tools/jls_decoder/JlsDecoder.csproj
|
||||
```
|
||||
|
||||
## Run GUI
|
||||
|
||||
```powershell
|
||||
dotnet run --project tools/jls_decoder/JlsDecoder.csproj
|
||||
dotnet run --project tools/jls_decoder/JlsDecoder.csproj -- path\to\file.rtljls
|
||||
```
|
||||
|
||||
## Hidden validation modes
|
||||
|
||||
Print decoded strip summary:
|
||||
|
||||
```powershell
|
||||
dotnet run --project tools/jls_decoder/JlsDecoder.csproj -- --probe path\to\file.charlsjls
|
||||
```
|
||||
|
||||
Export an auto-stretched preview PNG:
|
||||
|
||||
```powershell
|
||||
dotnet run --project tools/jls_decoder/JlsDecoder.csproj -- --export-preview path\to\file.rtljls out.png
|
||||
```
|
||||
|
||||
## Dependency note
|
||||
|
||||
The `CharLS.Native` NuGet package ships the native CharLS DLLs. On Windows, the
|
||||
Microsoft Visual C++ Redistributable for Visual Studio 2015-2022 may be needed
|
||||
on the target machine.
|
||||
233
tools/jls_test_images/generate_pgm_vectors.py
Normal file
233
tools/jls_test_images/generate_pgm_vectors.py
Normal file
@@ -0,0 +1,233 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
WIDTH = 6144
|
||||
HEIGHT = 256
|
||||
MAXVAL = 65535
|
||||
PERIOD = 256
|
||||
CHECKER_CELL = 32
|
||||
NOISE_SEED = 1
|
||||
CENTER_X = WIDTH // 2
|
||||
CENTER_Y = HEIGHT // 2
|
||||
PGM_HEADER = f"P5\n{WIDTH} {HEIGHT}\n{MAXVAL}\n".encode("ascii")
|
||||
RAW_NAME_RE = re.compile(r"^(?P<name>.+)-w256-h256-b8-r0\.img$")
|
||||
|
||||
|
||||
def put_u16be(buffer: bytearray, index: int, value: int) -> None:
|
||||
value &= 0xFFFF
|
||||
offset = index << 1
|
||||
buffer[offset] = (value >> 8) & 0xFF
|
||||
buffer[offset + 1] = value & 0xFF
|
||||
|
||||
|
||||
def write_pgm(path: Path, pixel_fn) -> None:
|
||||
data = bytearray(WIDTH * HEIGHT * 2)
|
||||
pixel_index = 0
|
||||
|
||||
for y in range(HEIGHT):
|
||||
for x in range(WIDTH):
|
||||
put_u16be(data, pixel_index, pixel_fn(x, y))
|
||||
pixel_index += 1
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(PGM_HEADER + data)
|
||||
|
||||
|
||||
def load_raw8(path: Path) -> bytes:
|
||||
raw = path.read_bytes()
|
||||
expected_size = 256 * 256
|
||||
if len(raw) != expected_size:
|
||||
raise ValueError(f"{path} size {len(raw)} != {expected_size}")
|
||||
return raw
|
||||
|
||||
|
||||
def phase_u16(phase: int, period: int = PERIOD) -> int:
|
||||
if period <= 1:
|
||||
return 0
|
||||
return (phase * MAXVAL) // (period - 1)
|
||||
|
||||
|
||||
def expanded_sources(repo_root: Path) -> list[Path]:
|
||||
source_dir = repo_root / "img"
|
||||
output_dir = source_dir / "patterns"
|
||||
generated = []
|
||||
|
||||
for raw_path in sorted(source_dir.glob("*.img")):
|
||||
match = RAW_NAME_RE.match(raw_path.name)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
name = match.group("name")
|
||||
raw = load_raw8(raw_path)
|
||||
output_path = output_dir / f"{name}-w{WIDTH}-h{HEIGHT}-s0-b16.pgm"
|
||||
|
||||
def pixel_fn(x: int, y: int, raw_data=raw) -> int:
|
||||
src_x = x % 256
|
||||
src_index = y * 256 + src_x
|
||||
sample8 = raw_data[src_index]
|
||||
return (sample8 << 8) | sample8
|
||||
|
||||
write_pgm(output_path, pixel_fn)
|
||||
generated.append(output_path)
|
||||
|
||||
return generated
|
||||
|
||||
|
||||
def build_patterns(repo_root: Path) -> list[Path]:
|
||||
output_dir = repo_root / "img" / "patterns"
|
||||
generated = []
|
||||
|
||||
def emit(name: str, pixel_fn) -> None:
|
||||
output_path = output_dir / f"{name}-w{WIDTH}-h{HEIGHT}-s0-b16.pgm"
|
||||
write_pgm(output_path, pixel_fn)
|
||||
generated.append(output_path)
|
||||
|
||||
emit("horizontal_stripes", lambda x, y: phase_u16(y % PERIOD))
|
||||
emit("vertical_stripes", lambda x, y: phase_u16(x % PERIOD))
|
||||
emit(
|
||||
"sine_stripes",
|
||||
lambda x, y: int(
|
||||
round((0.5 + 0.5 * math.sin((2.0 * math.pi * (x % PERIOD)) / PERIOD)) * MAXVAL)
|
||||
),
|
||||
)
|
||||
emit("sawtooth_stripes", lambda x, y: phase_u16(x % PERIOD))
|
||||
emit("diagonal_stripes", lambda x, y: phase_u16((x + y) % PERIOD))
|
||||
emit("anti_diagonal_stripes", lambda x, y: phase_u16((x - y) % PERIOD))
|
||||
emit(
|
||||
"concentric_stripes",
|
||||
lambda x, y: phase_u16(
|
||||
int(round(math.hypot(x - CENTER_X, y - CENTER_Y))) % PERIOD
|
||||
),
|
||||
)
|
||||
emit(
|
||||
"checkerboard",
|
||||
lambda x, y: MAXVAL if (((x // CHECKER_CELL) + (y // CHECKER_CELL)) & 1) else 0,
|
||||
)
|
||||
|
||||
rng = random.Random(NOISE_SEED)
|
||||
noise_values = [rng.randrange(0, MAXVAL + 1) for _ in range(WIDTH * HEIGHT)]
|
||||
emit("noise_uniform_seed1", lambda x, y, table=noise_values: table[y * WIDTH + x])
|
||||
|
||||
point_set = {
|
||||
(0, 0),
|
||||
(WIDTH - 1, 0),
|
||||
(0, HEIGHT - 1),
|
||||
(WIDTH - 1, HEIGHT - 1),
|
||||
(CENTER_X, CENTER_Y),
|
||||
}
|
||||
emit("point_targets", lambda x, y, pts=point_set: MAXVAL if (x, y) in pts else 0)
|
||||
emit("white", lambda x, y: MAXVAL)
|
||||
emit("black", lambda x, y: 0)
|
||||
emit(
|
||||
"diagonal_gradient",
|
||||
lambda x, y: ((x + y) * MAXVAL) // ((WIDTH - 1) + (HEIGHT - 1)),
|
||||
)
|
||||
|
||||
return generated
|
||||
|
||||
|
||||
def read_pgm_header(path: Path) -> tuple[int, int, int, int]:
|
||||
with path.open("rb") as handle:
|
||||
magic = handle.readline()
|
||||
dims = handle.readline()
|
||||
maxval = handle.readline()
|
||||
data_offset = handle.tell()
|
||||
|
||||
if magic != b"P5\n":
|
||||
raise ValueError(f"{path} magic {magic!r} != b'P5\\n'")
|
||||
|
||||
width_text, height_text = dims.strip().split()
|
||||
return int(width_text), int(height_text), int(maxval.strip()), data_offset
|
||||
|
||||
|
||||
def sample_u16be(path: Path, x: int, y: int) -> int:
|
||||
width, height, _, data_offset = read_pgm_header(path)
|
||||
if x < 0 or x >= width or y < 0 or y >= height:
|
||||
raise ValueError(f"sample out of range for {path}: ({x}, {y})")
|
||||
|
||||
offset = data_offset + ((y * width + x) << 1)
|
||||
with path.open("rb") as handle:
|
||||
handle.seek(offset)
|
||||
sample = handle.read(2)
|
||||
|
||||
return (sample[0] << 8) | sample[1]
|
||||
|
||||
|
||||
def validate_generated(repo_root: Path, generated: list[Path]) -> None:
|
||||
expected_size = len(PGM_HEADER) + WIDTH * HEIGHT * 2
|
||||
|
||||
for path in generated:
|
||||
width, height, maxval, _ = read_pgm_header(path)
|
||||
if width != WIDTH or height != HEIGHT or maxval != MAXVAL:
|
||||
raise ValueError(f"bad header for {path}: {width}x{height} max {maxval}")
|
||||
actual_size = path.stat().st_size
|
||||
if actual_size != expected_size:
|
||||
raise ValueError(f"bad size for {path}: {actual_size} != {expected_size}")
|
||||
|
||||
for raw_path in sorted((repo_root / "img").glob("*.img")):
|
||||
match = RAW_NAME_RE.match(raw_path.name)
|
||||
if not match:
|
||||
continue
|
||||
name = match.group("name")
|
||||
expanded_path = repo_root / "img" / "patterns" / f"{name}-w{WIDTH}-h{HEIGHT}-s0-b16.pgm"
|
||||
raw = load_raw8(raw_path)
|
||||
probe_points = [(0, 0), (255, 0), (256, 0), (1025, 17), (WIDTH - 1, HEIGHT - 1)]
|
||||
for x, y in probe_points:
|
||||
sample8 = raw[y * 256 + (x % 256)]
|
||||
expected = (sample8 << 8) | sample8
|
||||
actual = sample_u16be(expanded_path, x, y)
|
||||
if actual != expected:
|
||||
raise ValueError(
|
||||
f"expanded mismatch {expanded_path} ({x},{y}) {actual} != {expected}"
|
||||
)
|
||||
|
||||
point_path = repo_root / "img" / "patterns" / f"point_targets-w{WIDTH}-h{HEIGHT}-s0-b16.pgm"
|
||||
for x, y in [
|
||||
(0, 0),
|
||||
(WIDTH - 1, 0),
|
||||
(0, HEIGHT - 1),
|
||||
(WIDTH - 1, HEIGHT - 1),
|
||||
(CENTER_X, CENTER_Y),
|
||||
]:
|
||||
if sample_u16be(point_path, x, y) != MAXVAL:
|
||||
raise ValueError(f"point target missing at ({x}, {y})")
|
||||
|
||||
black_path = repo_root / "img" / "patterns" / f"black-w{WIDTH}-h{HEIGHT}-s0-b16.pgm"
|
||||
white_path = repo_root / "img" / "patterns" / f"white-w{WIDTH}-h{HEIGHT}-s0-b16.pgm"
|
||||
if sample_u16be(black_path, 0, 0) != 0 or sample_u16be(black_path, WIDTH - 1, HEIGHT - 1) != 0:
|
||||
raise ValueError("black image validation failed")
|
||||
if (
|
||||
sample_u16be(white_path, 0, 0) != MAXVAL
|
||||
or sample_u16be(white_path, WIDTH - 1, HEIGHT - 1) != MAXVAL
|
||||
):
|
||||
raise ValueError("white image validation failed")
|
||||
|
||||
noise_path = repo_root / "img" / "patterns" / f"noise_uniform_seed1-w{WIDTH}-h{HEIGHT}-s0-b16.pgm"
|
||||
noise_probe_points = [(0, 0), (1, 0), (2, 0), (WIDTH - 1, HEIGHT - 1)]
|
||||
noise_samples = [sample_u16be(noise_path, x, y) for x, y in noise_probe_points]
|
||||
noise_rng = random.Random(NOISE_SEED)
|
||||
noise_table = [noise_rng.randrange(0, MAXVAL + 1) for _ in range(WIDTH * HEIGHT)]
|
||||
expected_noise = [noise_table[y * WIDTH + x] for x, y in noise_probe_points]
|
||||
if noise_samples != expected_noise:
|
||||
raise ValueError(f"noise samples {noise_samples} != {expected_noise}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
generated = []
|
||||
generated.extend(expanded_sources(repo_root))
|
||||
generated.extend(build_patterns(repo_root))
|
||||
validate_generated(repo_root, generated)
|
||||
|
||||
print(f"Generated {len(generated)} PGM files.")
|
||||
for path in generated:
|
||||
print(path.relative_to(repo_root))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user