# The A320E: model tables, calibration, resolutions, origin Chip-level findings are in `hardware-gl128.md`. This document covers what is specific to the A320E as a product: the values in the model tables, how calibration had to be adapted, how the resolution model works, and the one user-visible control the patch adds. ## 1. The model entry | field | value | note | |---|---|---| | `name` | `plustek-opticpro-a320e` | | | `vendor` / `model` | `PLUSTEK` / `OpticPro A320E` | | | `asic_type` | `AsicType::GL124` | the chip is GL128; SANE has no such command set, and the register map is GL124's | | USB | `07b3:1826` | `181B` is the same scanner as internal model 2D1 | | `is_cis` | `false` | CCD | | `x_size` / `y_size` | 304.8 / 431.8 mm | 12000 / 17000 mils | | `x_offset` / `y_offset` | 18.35 / 14.7 mm | **measured on one unit**, see §5 | | `x_size_calib_mm` | 304.8 | see §3 for why this being a `float` matters | | `ld_shift_r/g/b` | 0 / 24 / 48 | measured, see below | | `bpp_gray` / `bpp_color` | {8} / {8} | | | method | flatbed only | | ### Line spacing between channels Measured in runs 139–142 by cross-correlating the channels' line profiles against a sharp horizontal line from a test grid. Uncorrected, R leads G by 6 lines and B trails it by 6 lines at 200 dpi. 6 lines at 200 dpi is 24 base step units (`base_ydpi` = 800), which lands exactly on the vendor's own figures: the INI says `"LineSpace"="24"` and the device description says `LINESPACE = 24`. With 0/24/48 the residual goes to zero on both channels. An earlier measurement gave 3 lines, back when vertical sampling was still halved by a step-size fault — so the shift follows physical distance, not a line count. Any y-direction measurement made before that fault was found has to be read against it. ### The `WARMUP` flag is absent on purpose Two reasons, and the second is the decisive one: 1. Both vendor definitions specify `WarmUpTime=0`, `FastWarmUpTime=0`, `LampType=1`. The lamp is an LED and needs no settling time; the genesys warmup loop exists for CCFLs. 2. Setting the flag **breaks the driver**: `gl124_init_regs_for_warmup` dies in `compute_session`. Enabling it would take an A320E branch there, not just the flag. ## 2. The resolution model The native series differ between the axes. This is the vendor's model, read off the captures and the macOS plist, and the driver follows it. **Horizontal.** The ASIC samples only at `dpihw × DPISET / 1200`, and `dpihw` is 400 (≤400 dpi) or 800 (≥600 dpi). The native values are therefore 100 / 200 / 400 / 800. 300 and 600 dpi are read on the 400 and 800 hardware and downscaled in the image pipeline — exactly as the vendor driver does. **Vertical.** The motor is free; the native values are 200 / 300 / 400 / 600 / 800. 100 dpi is not native there either: it runs at the 200 dpi step interval and the ASIC drops every other line (`LINESEL`). The menu the frontend sees is the union: **100 / 200 / 300 / 400 / 600 / 800**. ### 150 dpi is not offered It was the only mode non-native on *both* axes, and it could not be made to work: the ASIC produced no lines at all when line skipping was attempted on the 300 dpi hardware (runs 190, 198, 199). The vendor does not run it on the device either — it scales it from 200. Dropping it is harmless under SANE: `sanei_constrain_value` picks the nearest listed value and returns `SANE_STATUS_INEXACT`, so an application asking for 150 gets 200 dpi rather than an error. ### 100 dpi differs from the vendor's route The vendor runs 100 dpi on 200 dpi hardware and drops the lines in software. This driver uses the ASIC's own line skipping instead: yres 200 with `linesel = 1`. Measured at 0.998 normalised to 200 dpi (run 189). The reason the naive route does not work is that the motor's top speed is a plateau at step interval 782, which *is* 200 dpi. A target below that is clipped and the image stretches — 100 dpi measured 2.023x before the fix. The line skipping applies to image scans only. Feed and homing sessions run at yres 100, and for them a doubled `LINCNT` would confuse the readable line count, so they are excluded via the `FEEDING` and `REVERSE` flags. ### Non-integer resolution ratios `calculate_scan_settings` computed `requested_pixels` with an integer division `s->resolution / settings.xres`, which only supports "hardware coarser than requested → enlarge". When the hardware is *finer* the factor is 0, `requested_pixels` stays zero, and `get_requested_pixels()` falls back to the hardware width — giving an image of hardware width tagged with the requested resolution, i.e. stretched. That direction is needed as soon as a model's `resolutions_x` is sparser than its `resolutions_y`, which is this device. The factor is therefore computed as a ratio. In the integer cases the result is unchanged. ## 3. Calibration The A320E is the first non-LiDE device to reach much of this code, and several generic assumptions did not hold. **It runs at the scan's own optical resolution.** In this device's sensor table the optical resolution is 400 or 800 depending on what was requested. Using `full_resolution`, as the generic path does, exceeds it and `compute_session` throws "output resolution higher than optical resolution". **The exposure must come from the same sensor entry.** Otherwise the scan's `lperiod` (6256 at 200 dpi, say) matches no motor profile at the calibration resolution and `get_motor_profile` throws "Motor slope is not configured". **`lines = 1` calibrates nothing.** `dark_average_channel` starts its loop at row `y = 1` — upstream's own FIXME notes the first row carries noise from the previous scan. With one line the loop never runs, the average is always 0, and the offset calibration's binary search sees the same 0 for every AFE value. Only GL843 and CANON_5600F set this above 1; LiDE devices return earlier. The value used here is 8, the same as GL843's. **The pixel count has to be rounded up to the hardware's group size.** Two faults meet in one number. `Genesys_Model::x_size_calib_mm` is a `float`, so 304.8 is stored as 304.79998779296875 and `304.8 × 400 / 25.4` truncates from 4800 to **4799**. And GL124 processes pixels in groups of `full_resolution / optical_resolution` (800/400 = 2 here), so an odd request leaves the hardware with a partial group: the device sends the full group, the bulk read receives more than it asked for, and the result is `Overflow`. **Offset and gain use a 512-pixel window; shading uses the full width.** The vendor calibrates offset and gain with `STRPIXEL 23 / ENDPIXEL 535`. Narrowing the *shading* window the same way does not work, because `send_shading_data` indexes coefficients by absolute pixel: a 512 px calibration left the rest of a 100 mm window at zero (data 76.0 % → 8.6 %). The vendor evidently does not index its coefficients 1:1. **Gain is measured across the whole strip, at the 95th percentile.** Two departures from the generic path, for one reason. The 512 px edge window lies entirely inside the vignetting ramp — in the vendor's white data the level there is about 10 % of the centre — so a gain aimed at its mean overcompensates by roughly 10x and saturates the bright centre during white shading. And the gain has to keep the *brightest* part of the strip on scale rather than lift the mean to the target, so the statistic is the 95th percentile over the middle half, not the mean. **No white sheet is needed.** The device has an internal white strip under the frame at the home position: 0 → 10.46 mm, across the full 290 mm width, level 186 against a lid background of 67. Every calibration session already starts at `starty = 0`, and gain calibration returns the same values whether or not anything is on the glass (runs 187–188). An earlier claim in this project that no internal strip existed was an over-reading of a measurement that only covered the lid. **Coefficient parity filtering.** Our coefficients carried a ~9 % odd/even difference where the vendor's uploaded ones carry ~0.1 %, even though the parity of the raw data is the same ~2 % in both. The parity arises only in the calibration conditions on the strip — the staggered rows see different light under the frame — and is absent on the glass during a scan, so an unfiltered coefficient presses it into the image. The filter removes the local parity difference window by window (±16 px), preserving the vignetting profile and the per-pixel PRNU relative to the pixel's own parity group. This is the same idea as the vendor's `PixelGainFilter`. It fixes the period-2 ripple at 100–600 dpi. **Coefficient parity sign.** At 600 and 800 dpi the filter alone was not enough, and what remained looked random: some runs came out at 0.1–1 DN and others at 15–25 DN, with the same binary and the same target. Over 30 archived runs the sign of the loaded coefficients' parity predicted which, 14 times out of 14 — negative parity (even pixels below odd) gave a clean image, positive gave the ripple. The amplitude of the parity did not matter: within the positive group the ripple was 23.9–25.4 DN whether the parity was +31 % or +81 % of the level. That is the argument for what the draw actually is. If the taps were reversing in place, the ripple would follow the amplitude. It does not, which fits a **one-pixel shift of the calibration data relative to the image**: then the ripple is always twice the sensor's own tap difference, a device constant. So the driver normalises the sign rather than removing the parity. Per channel, after the gain filter: measure the level and the parity as the mean of `a(x) = (-1)^x (v[x] − (v[x−1]+v[x+1])/2)`; if `parity <= 0.05 × level`, do nothing (which leaves 100–400 dpi untouched — there the parity is 0.05–0.28 %); otherwise flip it with `v'(x) = v(x) − 2 d(x)`, where `d(x)` is the local parity deviation in the same ±16 px window. Flipping preserves the amplitude and the per-pixel PRNU relative to the pixel's own parity group; removing the parity would discard exactly the information the image needs to cancel against. Device-verified over runs 288–299: seven runs with the correction, five of which drew the wrong sign and were flipped and two of which drew the right sign and were left alone — **0.36–1.11 DN in all seven**. The A/B is run 298, same binary and target with the correction off and the wrong draw: 24.52 / 15.43 / 21.31 DN. **The white level is compensated in the gamma table, not in the divisor.** `target_code = 0x8000` maps the calibration white to half scale, and the margin is structural (`hardware-gl128.md` §4: the shading division wraps rather than clips). Plain paper measures 1.36x the internal strip, so it used to come out at 174 / 177 / 182 against the vendor's 251 / 254 / 255 — one fixed factor of ~1.42 across two different targets, not a tone curve, and with no empty histogram bins on either side, so both widths are produced before the quantisation to 8 bits. The compensation therefore goes where the ASIC applies it *after* the shading division and where overflow clips instead of wrapping — the gamma table (`a320e_apply_white_scale`, genesys.cpp): y = 1.40 x for y <= 0.90 y = 0.90 + (x − 0.643) × 0.28 above Monotonic, maps 1 to 1, never clips. Result on plain paper: 233 / 234 / 235, 0.00 % saturated. The vendor reaches 251 but saturates 13 / 40 / **99.4 %** of its R / G / B channels on that same sheet. Two gates around it, both of which matter if you touch this code: - **At 16 bpp the table is left out** (`should_enable_gamma` returns false at depth 16), so colorimetric data is still available. - **Gamma must not leak into calibration.** The A320E's sensor gamma is 1.0, which leaves the table unused, so `gl124.cpp` enables it explicitly — but only when the session is not a calibration one, which is marked by `DISABLE_SHADING`. Without that gate the coarse gain calibration (depth 8) would measure its own correction and drive the PGA to cancel it. **Black level per parity group.** `scanner_offset_calibration` aims at a target derived from the vendor's `2 × PixelDark`, and it reads the black level *per parity group* rather than as one median. Reading it as a single median hides the asymmetry described below and lets the search stop early; reading it per group halved the loaded dark coefficients (2432 / 2321 / 2240 → 1170 / 1145 / 1160 at 800 dpi) with the image level unchanged to within 0.2 DN8, which is what recovering ~2.7 % of full scale of dynamic range looks like. What is left is not a pedestal. The two parity groups differ in **sensitivity** — 4.8 / 4.3 / 1.8 % on R / G / B — and that difference scales with the signal and is independent of the PGA, so the black level has to stay high enough to hold it without the dark shading clipping. The AFE's offset sign and its gain registers were both measured against it and neither moves it; what remains untested is the sensor readout and the CDS sampling. ## 4. The feed moved a quarter of what was commanded `feedl = feed_steps << step_type`, so the driver assumes `move_dpi << step_type` = 200 << 1 = 400 pulses per inch. The hardware gives 1600: the motor's full step is 800 per inch, and it is driven in half steps. The factor of 4 is measured, not inferred. A series of `-t` offsets gave ratios 4.19 / 4.16 / 4.16, and a null test with a long lever (`-t` 14 and 28 mm) hit the prediction to within 0.4–0.5 lines; with a factor of 4.17 the prediction would have been off by 1.1 and 3.9 lines. The `/4` that causes it is in upstream's shared code and the LiDE models rely on it, so the correction is restricted by `ModelId`. ## 5. Origin adjustment `x_offset` and `y_offset` above are measured values, and they were measured on **one unit**. Another user's scanner will not have its mechanical origin in the same place, and until now there was no way to correct that. The patch adds two options, defaulting to 0, so behaviour is unchanged for anyone who leaves them alone: | option | unit | range | |---|---|---| | `x-origin-offset` | mm | −5 … +5 | | `y-origin-offset` | mm | −5 … +5 | Both are marked advanced and are enabled only for this model. The vendor's counterpart is `AdjustOrigin`, a persistent user setting; this is its run-time form. Two implementation notes worth carrying to any similar option. The carriage cannot travel behind the home position, so a negative sum is clipped to zero — without the clipping the `static_cast` downstream wraps around. And the device-side fields have to be cleared in `init_options`, not merely the scanner-side ones: `Genesys_Device` lives for the life of the process while `Genesys_Scanner` is recreated on every `sane_open`, so otherwise an open/set/close/open sequence leaves the old shift in force while the option reports zero. ## 6. Fields left unfinished Marked as such in the model table, and listed here so they are not mistaken for measured values: - `y_size_calib_mm = 3.0` — not measured. - `line_mode_color_order = ColorOrder::RGB` — unverified. - `stagger_y` — the vendor defines `StaggeredLine = -4`, but SANE implements staggering through its own machinery and the two have not been reconciled. - The black level runs 8x / 15x / 31x the vendor's (R/G/B). The target is already the vendor's own; the residue is the parity groups' sensitivity difference, which the black level has to accommodate (§3). This costs dynamic range. - The period-2 ripple is at 0.4–1.1 DN, except that at 800 dpi the green channel moved from 1.09 to 1.26 DN when the black level was fixed. Not chased; it is at the noise floor.