Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,11 @@ add_executable(codestream_bounds_check)
add_subdirectory(tests/tools/codestream_bounds_check)
target_link_libraries(codestream_bounds_check PUBLIC open_htj2k)

# visual_weight_check: dump + invariant self-check for the analytic per-subband
# visual (CSF) weighting (header-only, no library dependency)
add_executable(visual_weight_check)
add_subdirectory(tests/tools/visual_weight_check)

# region_decode_bench: M1 region-decode latency/throughput benchmark (OSD eval)
add_executable(region_decode_bench)
add_subdirectory(tests/tools/region_decode_bench)
Expand Down
Binary file added conformance_data/references/synth422_cb.pgx
Binary file not shown.
Binary file added conformance_data/references/synth422_cr.pgx
Binary file not shown.
Binary file added conformance_data/references/synth422_y.pgx
Binary file not shown.
11 changes: 11 additions & 0 deletions docs/cli_encoder.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,17 @@ JPH file format.
- `Qzoom=Float` *(experimental)*
- Display magnification; `> 1` is zoom-in, which flattens the
weighting toward flat MSE-optimal quantization. Default **1.0**.
- `Qctype=T,T,T` *(experimental)*
- Per-component role hints (`Y|Cb|Cr|generic`) for the analytic models
when the input is pre-decorrelated YCbCr **without** an MCT — the
sub-sampled 4:2:0 / 4:2:2 configuration, where the codestream cannot
label channels itself. `Qctype=Y,Cb,Cr` selects the chroma CSF (with
the component's sub-sampling frequency shift) for components 1 and 2.
Default **generic** (luminance CSF).
- `Qchromacsf=chroma|luma` *(experimental)*
- CSF shape used for `Cb`/`Cr` components: the low-pass chroma CSF
(default) or the luminance CSF (`luma`), keeping the sub-sampling
frequency mapping — an A/B switch separating shape from mapping.
- See [`qfactor.md`](qfactor.md) for the analytic visual-weighting model.

### JPH and component layout
Expand Down
51 changes: 47 additions & 4 deletions docs/qfactor.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,36 @@ parameter pair per opponent channel calibrated at `Qppd=72`:

Chroma subsampling is **not** a separate model: 4:2:0 / 4:2:2 are the same CSF
sampled at frequencies shifted by the horizontal / vertical subsampling factors
`(sx, sy)`. A 4:2:2 channel therefore gets different `LH` (vertical detail) and
`HL` (horizontal detail) weights for free.
`(sx, sy)` — taken per component from the SIZ `XRsiz`/`YRsiz` fields. A
component sub-sampled by `s` on an axis sits on a grid `s×` coarser, so that
axis's angular frequency divides by `s`. Two consequences fall out of the
mapping with no special-casing:

- **Isotropic 4:2:0 is exactly a one-level shift.** Halving both axes halves
every subband's radial frequency, which is identical to moving one
decomposition level coarser — the classic "weight chroma like luma one level
down" rule. Do not add a manual level offset on top; the `(sx, sy)` scaling
already subsumes it (this equivalence is asserted bit-for-bit by
`visual_weight_check check`).
- **4:2:2 is anisotropic.** Only the horizontal axis is halved, so a 4:2:2
channel gets different `LH` (vertical detail) and `HL` (horizontal detail)
weights — something no scalar per-subband table can represent.

**Component roles need a hint when chroma is sub-sampled.** Sub-sampled
components rule out the built-in MCT (it needs identically-sized components),
and without an MCT the codestream does not label channels — the encoder cannot
know component 1 is `Cb`. Pass `Qctype=Y,Cb,Cr` to select the chroma CSF;
unhinted components default to `generic` (luminance CSF, still with the
`(sx, sy)` frequency shift). `Qchromacsf=luma` is an A/B switch that keeps the
mapping but swaps the chroma shape for the luminance CSF.

The `visual_weight_check` test tool (built with the test suite) dumps the
derived table for any configuration, e.g. for a 4:2:2 `Cb` channel:

```bash
visual_weight_check dump --ctype Cb --sx 2 --sy 1 # analytic
visual_weight_check dump --ctype Cb --legacy-format 422 # legacy table row
```

### Colour transform and gains

Expand All @@ -155,16 +183,24 @@ reconstructed RGB. The model in force is resolved per encode:

Legacy mode always assumes `ict` (reproducing historical behaviour); analytic
modes honour the MCT actually applied, so an undecorrelated RGB encode gets unit
gains and the **luminance** CSF on every channel (never the chroma roll-off).
gains and the **luminance** CSF on every channel. The one exception is a
`Qctype` hint: a no-MCT component labelled `Cb`/`Cr` (pre-decorrelated,
typically sub-sampled YCbCr input) takes the chroma CSF while keeping unit
colour gain — there is no inverse transform amplifying its error, but its
content is still chrominance.

## CLI reference

All three are encoder options and require `Qfactor`:
All of these are encoder options and require `Qfactor`:

- `Qcsf=legacy|mannos|daly` — visual-weighting model. Default **legacy**
(bit-identical). `mannos` / `daly` are experimental.
- `Qppd=Float` — reference pixels-per-degree at zoom 1.0. Default **72**.
- `Qzoom=Float` — display magnification; `> 1` is zoom-in. Default **1.0**.
- `Qctype=T,T,T` — per-component role hints (`Y|Cb|Cr|generic`) for analytic
models on no-MCT (e.g. sub-sampled YCbCr) input. Default **generic**.
- `Qchromacsf=chroma|luma` — CSF shape for `Cb`/`Cr` components. Default
**chroma** (the low-pass chroma CSF).

When an analytic model is selected the encoder prints an `EXPERIMENTAL:` status
line; selecting one without `Qfactor` prints a warning and has no effect.
Expand All @@ -177,6 +213,10 @@ encoder.set_output_buffer(out);
// EXPERIMENTAL: model 0 = legacy (default), 1 = Mannos–Sakrison, 2 = Daly.
// ref_ppd / zoom ≤ 0 keep their defaults. Call before invoke_*.
encoder.set_visual_weighting(/*model=*/1, /*ref_ppd=*/72.0, /*zoom=*/2.0);
// EXPERIMENTAL: component roles for no-MCT input (0 generic, 1 Y, 2 Cb, 3 Cr)
// and the chroma-CSF A/B switch — the Qctype= / Qchromacsf= equivalents.
encoder.set_component_types(1, 2, 3);
encoder.set_chroma_csf_reuse_luma(false);
encoder.invoke_line_based();
```

Expand All @@ -198,6 +238,9 @@ estimate_qfactor out.j2c
# Analytic encode — pass the same model / viewing condition used at encode time
estimate_qfactor out.j2c --csf mannos --zoom 2

# Sub-sampled YCbCr analytic encode — pass the same role hints too
estimate_qfactor out420.j2c --csf mannos --ctype Y,Cb,Cr

# Scriptable check mode (CI): assert the recovered Q and a residual ceiling
estimate_qfactor out.j2c --csf mannos --expect-q 90 --max-residual 0.01
```
Expand Down
56 changes: 52 additions & 4 deletions source/apps/encoder/enc_utils.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ void print_help(char *cmd) {
"Qppd=Float (EXPERIMENTAL) reference pixels-per-degree at zoom 1.0 (default 72).\n"
"Qzoom=Float (EXPERIMENTAL) display magnification, >1 = zoom-in (default 1.0);\n"
" zoom-in flattens the weighting toward flat MSE-optimal quantization.\n");
printf(
"Qctype=T,T,T (EXPERIMENTAL) per-component role hints (Y|Cb|Cr|generic) for the\n"
" analytic models when input is pre-decorrelated YCbCr (no MCT), e.g. sub-sampled\n"
" 4:2:0/4:2:2: the codestream cannot label channels, so Qctype=Y,Cb,Cr selects the\n"
" chroma CSF for components 1 and 2. Default generic (luminance CSF).\n"
"Qchromacsf=chroma|luma (EXPERIMENTAL) CSF shape for Cb/Cr: low-pass chroma CSF\n"
" (default) or reuse the luminance CSF (A/B-tests shape vs sub-sampling mapping).\n");
printf(
"-jph_color_space\n"
" Color space of input components: Valid entry is one of RGB, YCC.\n If inputs are represented in "
Expand Down Expand Up @@ -154,9 +161,11 @@ class j2k_argset {
bool qderived;
uint8_t qfactor;
// EXPERIMENTAL analytic visual (CSF) weighting for the Qfactor path.
uint8_t csf_model; // 0 = legacy table (default), 1 = Mannos-Sakrison, 2 = Daly
double csf_ppd; // reference pixels-per-degree at zoom 1.0
double csf_zoom; // display magnification (> 1 = zoom-in)
uint8_t csf_model; // 0 = legacy table (default), 1 = Mannos-Sakrison, 2 = Daly
double csf_ppd; // reference pixels-per-degree at zoom 1.0
double csf_zoom; // display magnification (> 1 = zoom-in)
uint8_t csf_ctype[3]; // per-component role hints: 0 generic, 1 Y, 2 Cb, 3 Cr
bool csf_chroma_reuse_luma; // A/B: luminance CSF shape for Cb/Cr components

static void get_coordinate(const std::string &param_name, std::string &arg, element_siz_local &dims) {
size_t pos0, pos1;
Expand Down Expand Up @@ -411,6 +420,8 @@ class j2k_argset {
csf_model(0),
csf_ppd(72.0),
csf_zoom(1.0),
csf_ctype{0, 0, 0},
csf_chroma_reuse_luma(false),
ifnames{},
num_iteration(1),
num_threads(0),
Expand All @@ -427,7 +438,7 @@ class j2k_argset {
is_i_found = true;
} else if ((tmp.front() == '-' || tmp.front() == 'C' || tmp.front() == 'S' || tmp.front() == 'Q')
&& is_i_found) {
if (tmp[1] == ':') continue; // for Windows
if (tmp[1] == ':') continue; // for Windows
fname_stop = i;
is_i_found = false;
}
Expand Down Expand Up @@ -596,6 +607,41 @@ class j2k_argset {
csf_ppd = get_numerical_param(c, param, arg, 1.0, 100000.0);
} else if (param == "zoom") {
csf_zoom = get_numerical_param(c, param, arg, 0.01, 1000.0);
} else if (param == "ctype") {
// EXPERIMENTAL: comma-separated per-component role hints for analytic
// weighting of sub-sampled (no-MCT) input, e.g. Qctype=Y,Cb,Cr.
std::string val = arg.substr(arg.find_first_of('=') + 1);
size_t idx = 0, pos = 0;
while (idx < 3 && pos <= val.size()) {
size_t comma = val.find(',', pos);
std::string token = val.substr(pos, (comma == std::string::npos) ? comma : comma - pos);
if (token == "Y" || token == "y") {
csf_ctype[idx] = 1;
} else if (token == "Cb" || token == "cb" || token == "CB") {
csf_ctype[idx] = 2;
} else if (token == "Cr" || token == "cr" || token == "CR") {
csf_ctype[idx] = 3;
} else if (token == "generic" || token == "-") {
csf_ctype[idx] = 0;
} else {
printf("ERROR: unknown Qctype entry '%s' (use Y|Cb|Cr|generic)\n", token.c_str());
exit(EXIT_FAILURE);
}
++idx;
if (comma == std::string::npos) break;
pos = comma + 1;
}
} else if (param == "chromacsf") {
// EXPERIMENTAL: chroma CSF shape selection (A/B vs the frequency mapping).
std::string val = arg.substr(arg.find_first_of('=') + 1);
if (val == "chroma") {
csf_chroma_reuse_luma = false;
} else if (val == "luma") {
csf_chroma_reuse_luma = true;
} else {
printf("ERROR: unknown Qchromacsf value '%s' (use chroma|luma)\n", val.c_str());
exit(EXIT_FAILURE);
}
} else {
printf("ERROR: unknown parameter Q%s\n", param.c_str());
exit(EXIT_FAILURE);
Expand Down Expand Up @@ -633,4 +679,6 @@ class j2k_argset {
OPENHTJ2K_NODISCARD uint8_t get_csf_model() const { return csf_model; }
OPENHTJ2K_NODISCARD double get_csf_ppd() const { return csf_ppd; }
OPENHTJ2K_NODISCARD double get_csf_zoom() const { return csf_zoom; }
OPENHTJ2K_NODISCARD uint8_t get_csf_ctype(size_t c) const { return (c < 3) ? csf_ctype[c] : 0; }
OPENHTJ2K_NODISCARD bool get_csf_chroma_reuse_luma() const { return csf_chroma_reuse_luma; }
};
13 changes: 8 additions & 5 deletions source/apps/encoder/main_enc.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@
// Stream readers (PNM / PGX / TIFF) live in the openhtj2k_imgio static lib;
// see source/apps/imgio/imgio.hpp.


int main(int argc, char *argv[]) {
j2k_argset args(argc, argv); // parsed command line
std::vector<std::string> fnames = args.ifnames;
Expand Down Expand Up @@ -98,8 +97,8 @@ int main(int argc, char *argv[]) {
std::string fext = out_filename.substr(pos, 4);
if (fext == ".jph" || fext == ".JPH") {
isJPH = true;
} else if (fext.compare(".j2c") && fext.compare(".j2k") && fext.compare(".jphc") && fext.compare(".J2C")
&& fext.compare(".J2K") && fext.compare(".JPHC")) {
} else if (fext.compare(".j2c") && fext.compare(".j2k") && fext.compare(".jphc")
&& fext.compare(".J2C") && fext.compare(".J2K") && fext.compare(".JPHC")) {
printf("ERROR: invalid extension for output file\n");
exit(EXIT_FAILURE);
}
Expand All @@ -119,8 +118,8 @@ int main(int argc, char *argv[]) {
if (!reader) return EXIT_FAILURE;
element_siz_local image_origin_s = args.get_origin();
element_siz_local image_size_s(reader->get_width(), reader->get_height());
stat_width = reader->get_width();
stat_height = reader->get_height();
stat_width = reader->get_width();
stat_height = reader->get_height();
uint16_t nc_s = reader->get_num_components();
uint8_t bd_s = reader->get_bitdepth(0);

Expand Down Expand Up @@ -181,6 +180,8 @@ int main(int argc, char *argv[]) {
if (!toFile) encoder.set_output_buffer(outbuf);
// EXPERIMENTAL: analytic visual weighting (Qcsf/Qppd/Qzoom); legacy default is a no-op.
encoder.set_visual_weighting(args.get_csf_model(), args.get_csf_ppd(), args.get_csf_zoom());
encoder.set_component_types(args.get_csf_ctype(0), args.get_csf_ctype(1), args.get_csf_ctype(2));
encoder.set_chroma_csf_reuse_luma(args.get_csf_chroma_reuse_luma());
try {
total_size = encoder.invoke_line_based_stream(
[&reader](uint32_t y, int32_t **rows, uint16_t nc) { reader->get_row(y, rows, nc); });
Expand Down Expand Up @@ -267,6 +268,8 @@ int main(int argc, char *argv[]) {
if (!toFile) encoder.set_output_buffer(outbuf);
// EXPERIMENTAL: analytic visual weighting (Qcsf/Qppd/Qzoom); legacy default is a no-op.
encoder.set_visual_weighting(args.get_csf_model(), args.get_csf_ppd(), args.get_csf_zoom());
encoder.set_component_types(args.get_csf_ctype(0), args.get_csf_ctype(1), args.get_csf_ctype(2));
encoder.set_chroma_csf_reuse_luma(args.get_csf_chroma_reuse_luma());
try {
total_size = encoder.invoke_line_based();
} catch (std::exception &exc) {
Expand Down
Loading
Loading