Initial JPEG-LS FPGA encoder baseline with tooling and timeout fix

This commit is contained in:
2026-04-16 18:55:08 +08:00
commit e4fdbdfeec
150 changed files with 25796 additions and 0 deletions

View 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.

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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>

View 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;
}
}

View 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);
}
}

View 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}");
}
}

View 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.

View 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()