unpixel

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 18, 2026 License: GPL-3.0 Imports: 4 Imported by: 0

README

UnPixel

A faithful pure-Go port of Bishop Fox's unredacter — reconstructs text hidden behind pixelation/mosaic redaction. Background: Never use pixelation to redact text.

CI Go Reference Go Report Card Go 1.26 License GPL-3.0-or-later

Status: the core library is usable (~94% test coverage, all gates green). The CLI (cmd/unpixel) is still a placeholder. See PROGRESS.md for the roadmap.

Table of contents

How it works

Pixelation is a deterministic function of its input, so UnPixel doesn't "un-blur" anything — it runs generate-and-test. It renders a candidate string in the target font, re-pixelates it with the same block grid as the redacted region, scores the image distance against the target, and drives a guided depth-first search over candidate strings: discover the grid offset, then extend the plaintext character by character, pruning branches as soon as the re-pixelated output stops matching. Because pixelation averages each block, only the true text reproduces the target's blocks exactly.

See docs/DESIGN.md for the algorithm and the library choices behind it.

Features

  • Pure Go, no CGO. Deterministic, statically linked, cross-compilable — no C toolchain.
  • Library-agnostic progress API. Run streams typed Progress events on a buffered channel (best guess, current candidate, score, depth, offsets probed/total, evaluated count, elapsed), so any UI — web/SSE, TUI, desktop — can subscribe via the channel or the OnProgress callback.
  • Pluggable everything. Swap the Renderer, Pixelator, Metric, or search Strategy through Config; the faithful defaults are wired by importing the defaults package.
  • Self-consistent correctness. Fidelity is judged by a redaction round-trip (redact a known plaintext, then recover it). Matching a Chromium-rendered redaction is a documented Phase-2 goal (needs a chromedp renderer).
  • ~94% test coverage across rendering, pixelation, metrics, search, and end-to-end.

Install

As a library:

go get github.com/oioio-space/unpixel

From source (development) — uses mise for the toolchain and tasks:

git clone https://github.com/oioio-space/unpixel.git
cd unpixel
mise run setup     # install pinned tools + wire git hooks
mise run test      # verify the build
mise run           # list all tasks

Requires Go 1.26+.

Quick start

Recover the text behind a pixelated PNG:

package main

import (
	"context"
	"fmt"
	"image/png"
	"os"

	"github.com/oioio-space/unpixel"
	_ "github.com/oioio-space/unpixel/defaults" // wires the default renderer/pixelator/metric/strategy
)

func main() {
	f, err := os.Open("redacted.png")
	if err != nil {
		panic(err)
	}
	defer f.Close()

	img, err := png.Decode(f)
	if err != nil {
		panic(err)
	}

	eng, err := unpixel.New(img, unpixel.Config{}) // zero Config = faithful defaults
	if err != nil {
		panic(err)
	}

	progress, results := eng.Run(context.Background())
	go unpixel.OnProgress(progress, func(p unpixel.Progress) {
		fmt.Printf("\rbest: %-20q (%.3f)", p.BestGuess, p.BestScore)
	})

	fmt.Println("\nrecovered:", (<-results).BestGuess)
}

Public API (root package unpixel):

Symbol Purpose
New(redacted image.Image, cfg Config) (*Engine, error) Build an engine; zero Config = faithful defaults
(*Engine).Run(ctx) (<-chan Progress, <-chan Result) Run the search; stream progress, deliver the result
OnProgress(ch <-chan Progress, fn func(Progress)) Drain progress events into a callback (any UI)
Renderer, Pixelator, Metric, Strategy Pluggable pipeline interfaces
Config, Style, Result, Eval, Offset, Progress, EventKind Configuration and result/event types

Configuration

Pass a Config to unpixel.New. Every zero value falls back to a documented default.

Field Type Default Meaning
Charset string "abcdefghijklmnopqrstuvwxyz " Candidate characters to search
MaxLength int 20 Maximum plaintext length
BlockSize int 8 Pixelation block size, in pixels
Threshold float64 0.25 Max image-distance score (0–1) to keep a candidate
SpaceThreshold float64 0.5 Looser threshold for extending with a space (whitespace blur)
ThresholdFor func(rune) float64 space→SpaceThreshold, else Threshold Per-character threshold; override for new char classes
Style Style Liberation Sans, 32 px, white bg Font family/size/weight/padding for rendering
Renderer Renderer x/image/font (pure Go) Text → raster
Pixelator Pixelator block-average Raster → pixelated
Metric Metric orisano/pixelmatch Image-distance score
Strategy Strategy guided DFS Candidate-space search

Architecture

Package layout
github.com/oioio-space/unpixel
├── unpixel.go              # Engine, Config, Result, Eval, Offset, Progress; the 4 interfaces
├── defaults/               # wires the default components (breaks the root↔internal import cycle)
├── internal/
│   ├── imutil/             # crop / pad / compose; blueMargin & leftEdge detection
│   ├── pixelate/           # block-average pixelator; grid-origin crop; white padding
│   ├── metric/             # pixelmatch (faithful default) + simple RGB metric
│   ├── render/             # pure-Go x/image/font renderer
│   │   └── fonts/          #   embedded Liberation Sans (Regular/Bold) + OFL license
│   └── search/             # offset discovery + marginal cropping + guided DFS
└── cmd/unpixel/            # CLI (placeholder)

The four interfaces (Renderer, Pixelator, Metric, Strategy) live in the root package so they can be implemented and injected from outside; the concrete implementations stay under internal/.

Rendering fidelity & Phase-2 roadmap

The original unredacter rasterizes via Chromium; UnPixel uses pure-Go golang.org/x/image/font. Byte-identical glyphs across engines aren't possible (different hinting/anti-aliasing), so correctness is judged by self-consistency: redact a known plaintext with UnPixel's own renderer, then recover it. Recovering a Chromium-produced redaction (e.g. the original secret.png) is a Phase-2 goal requiring a chromedp renderer.

Phase-2 ideas (behind the interfaces; the faithful default stays put): beam search, goroutine fan-out over candidates/offsets, SSIM / edge-aware metrics, automatic block-size & offset inference, the chromedp fidelity renderer, and top-N confidence/ambiguity reporting. Details in docs/DESIGN.md § Phase-2.

Contributing

mise run test:watch   # TDD loop
mise run lint         # gofmt + go vet + golangci-lint
mise run test         # tests
mise run cover        # coverage report (floor: 85%, currently ~94%)
mise run bench        # hot-path benchmarks (benchstat-proven)
mise run ci           # everything CI runs

Commits go through git hooks — artifacts → secrets (gitleaks) → vulns (gosec + govulncheck) → style (golangci-lint) → cgo:check — plus a /simplify review. CI re-runs all of it and adds a CycloneDX SBOM scanned by grype, a full-history secret scan, and the coverage floor.

Two absolute project rules: the project stays pure Go (no CGO), and the hot path is benchmarked with benchstat-proven changes. See CLAUDE.md for tooling and PROGRESS.md for the roadmap.

Credits

License

GPL-3.0-or-later — see LICENSE. UnPixel is a derivative work of Bishop Fox's unredacter (GPL-3.0); the copyleft license is preserved. © the UnPixel authors; original © Bishop Fox.

Documentation

Overview

Package unpixel reconstructs text hidden behind pixelation.

UnPixel attempts to recover the original text from a pixelated (mosaic-blurred) image region. The algorithm works by rendering each candidate string into an off-screen image, re-applying the same block-average pixelation with the same grid origin, and measuring the pixel-level distance against the redacted region. A guided depth-first search explores the candidate space character by character, pruning branches whose cumulative score exceeds the configured threshold.

Usage

Obtain or synthesise an image.Image containing the pixelated region, construct an Engine with New, and call Run. Import the defaults package for its side-effect to wire the standard renderer, pixelator, metric, and search strategy; without it the components must be supplied explicitly in Config.

import _ "github.com/oioio-space/unpixel/defaults" // wire default components

eng, err := unpixel.New(img, unpixel.Config{})
if err != nil { ... }
progCh, resCh := eng.Run(ctx)
unpixel.OnProgress(progCh, func(p unpixel.Progress) {
    fmt.Println(p.BestGuess)
})
for r := range resCh {
    fmt.Println(r.BestGuess, r.BestScore)
}

UnPixel is a faithful Go port of Bishop Fox's unredacter (GPL-3.0).

Example

Example demonstrates the quick-start usage of UnPixel: construct an Engine from a pixelated image, run the search, consume progress events, then collect results. The image here is a small synthetic white rectangle used purely to keep the example self-contained and fast; in practice, supply the actual pixelated screenshot region.

package main

import (
	"context"
	"fmt"
	"image"
	"image/color"

	"github.com/oioio-space/unpixel"
	_ "github.com/oioio-space/unpixel/defaults"
)

func main() {
	// Build a tiny synthetic image to stand in for a real pixelated screenshot.
	img := image.NewRGBA(image.Rect(0, 0, 64, 16))
	for y := range img.Bounds().Dy() {
		for x := range img.Bounds().Dx() {
			img.SetRGBA(x, y, color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
		}
	}

	eng, err := unpixel.New(img, unpixel.Config{
		Charset:   "ab ",
		MaxLength: 2,
		BlockSize: unpixel.DefaultBlockSize,
	})
	if err != nil {
		fmt.Println("new:", err)
		return
	}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	progCh, resCh := eng.Run(ctx)

	// Consume progress events concurrently while collecting results below.
	// OnProgress blocks until the progress channel is closed (EventDone).
	done := make(chan struct{})
	go func() {
		defer close(done)
		unpixel.OnProgress(progCh, func(p unpixel.Progress) {
			if p.Kind == unpixel.EventNewBest {
				fmt.Println("new best:", p.BestGuess)
			}
		})
	}()

	for r := range resCh {
		if r.Err != nil {
			fmt.Println("result error:", r.Err)
			continue
		}
		fmt.Println("result:", r.BestGuess)
	}
	<-done
}

Index

Examples

Constants

View Source
const (
	// DefaultCharset is the candidate alphabet: lowercase ASCII letters and space.
	DefaultCharset = "abcdefghijklmnopqrstuvwxyz "
	// DefaultMaxLength is the maximum candidate string length the search explores.
	DefaultMaxLength = 20
	// DefaultBlockSize is the pixelation block side length in pixels.
	DefaultBlockSize = 8
	// DefaultThreshold is the per-block score gate for non-space characters;
	// candidates scoring above this value are pruned.
	DefaultThreshold = 0.25
	// DefaultSpaceThreshold is the per-block score gate for the space character,
	// which is visually ambiguous and requires a more permissive threshold.
	DefaultSpaceThreshold = 0.5
)

Default configuration values, matching the original unredacter reference implementation.

Variables

View Source
var DefaultComponents func(cfg *Config) error

DefaultComponents is a hook that wires the standard Renderer, Pixelator, Metric, and Strategy into cfg for any fields that are still nil. It is populated by importing the defaults package for its side-effect, which avoids an import cycle between the root package and its internal implementations. Applications that supply all component fields explicitly need not set this.

View Source
var ErrNilImage = errors.New("redacted image must not be nil")

ErrNilImage is returned by New when a nil image is passed.

Functions

func OnProgress

func OnProgress(ch <-chan Progress, fn func(Progress))

OnProgress drains the progress channel returned by Engine.Run, calling fn for every event in order. It blocks until the channel is closed (i.e. until EventDone has been delivered and consumed). It is a convenience wrapper around a range loop and does not start a goroutine.

Types

type Config

type Config struct {
	// Charset is the ordered set of candidate characters tried at each search
	// depth. Defaults to DefaultCharset (a–z plus space).
	Charset string
	// MaxLength is the maximum number of characters the search will attempt
	// before backtracking. Defaults to DefaultMaxLength.
	MaxLength int
	// BlockSize is the side length in pixels of each pixelation block. It must
	// match the block size used to produce the redacted image. Defaults to
	// DefaultBlockSize.
	BlockSize int
	// Threshold is the per-block score below which a non-space candidate is
	// accepted and the search descends deeper. Lower values are stricter.
	// Defaults to DefaultThreshold.
	Threshold float64
	// SpaceThreshold is the acceptance threshold applied specifically to the
	// space character, which is harder to distinguish visually. Defaults to
	// DefaultSpaceThreshold.
	SpaceThreshold float64
	// ThresholdFor returns the acceptance threshold for a given candidate
	// character. It defaults to a closure that returns SpaceThreshold for ' '
	// and Threshold for all other runes. Override to apply per-class thresholds
	// without modifying search logic.
	ThresholdFor func(rune) float64

	// Style controls the font size, weight, and padding used when rendering
	// candidates. Zero fields use the design defaults (32 pt, 8 px padding).
	Style Style

	// Renderer is the component that rasterises candidate strings to RGBA.
	// Defaults to the XImage renderer (wired by the defaults package).
	Renderer Renderer
	// Pixelator is the component that applies block-average pixelation.
	// Defaults to BlockAverage (wired by the defaults package).
	Pixelator Pixelator
	// Metric is the component that measures pixel-level image distance.
	// Defaults to Pixelmatch (wired by the defaults package).
	Metric Metric
	// Strategy is the component that drives the search algorithm.
	// Defaults to GuidedDFS (wired by the defaults package).
	Strategy Strategy
}

Config holds all tunable parameters for an Engine. Every field is optional: zero values are replaced by the package defaults (see the Default* constants) when New is called. Component fields (Renderer, Pixelator, Metric, Strategy) are filled by importing the defaults package for its side-effect, or can be supplied directly for custom implementations.

type Engine

type Engine struct {
	// contains filtered or unexported fields
}

Engine orchestrates the full unpixel pipeline: grid-origin discovery, candidate rendering, re-pixelation, distance measurement, and guided search. Create one with New and call Run to start the search.

func New

func New(redacted image.Image, cfg Config) (*Engine, error)

New creates an Engine for the given pixelated image. Scalar zero values in cfg are replaced by package defaults; nil component fields are left for Run to fill via DefaultComponents (or must be set explicitly). New returns ErrNilImage if redacted is nil.

func (*Engine) Run

func (e *Engine) Run(ctx context.Context) (<-chan Progress, <-chan Result)

Run starts the search and returns a progress channel and a results channel. The progress channel carries Progress events and is closed after EventDone is delivered. The results channel receives one Result per surviving grid offset and is closed once all offsets have been searched. Cancelling ctx causes both channels to be closed promptly after the in-flight search step completes.

If any component field in cfg is nil, Run invokes DefaultComponents to fill it before starting. If DefaultComponents is also nil, Run panics with a descriptive message directing the caller to import the defaults package.

type Eval

type Eval struct {
	// Guess is the candidate string evaluated at this node.
	Guess string
	// Score is the marginal region diff score for the most recently added
	// character (lower is better; compared against the threshold).
	Score float64
	// TotalScore is the whole-image diff score accumulated so far (used for
	// display and ranking, not for pruning).
	TotalScore float64
	// TooBig is true when the rendered candidate is wider than the redacted
	// image; such candidates are pruned regardless of score.
	TooBig bool
	// GuessImage is a cropped, pixelated rendering of Guess, populated only
	// when the caller opts in via the Strategy implementation.
	GuessImage *image.RGBA
}

Eval holds the result of evaluating a single candidate string at one search node.

type EventKind

type EventKind int

EventKind identifies the type of a Progress event delivered on the channel returned by Engine.Run.

const (
	// EventCandidate is emitted for every candidate string evaluated during
	// the search. It is high-frequency; the channel is non-blocking and events
	// are dropped when the buffer is full.
	EventCandidate EventKind = iota
	// EventOffsetProbed is emitted after each grid-origin probe during the
	// offset-discovery phase. It is high-frequency with the same drop semantics
	// as EventCandidate.
	EventOffsetProbed
	// EventNewBest is emitted whenever the search finds a new overall best
	// guess. Delivery is guaranteed: the sender blocks (with context) until the
	// consumer reads the event.
	EventNewBest
	// EventDone is the terminal event, delivered exactly once after all offsets
	// have been searched or the context is cancelled. Delivery is guaranteed.
	EventDone
)

type Metric

type Metric interface {
	Compare(a, b *image.RGBA) float64
}

Metric measures the visual distance between two same-sized RGBA images. Compare returns a value in [0, 1] where 0 means pixel-perfect identical.

type Offset

type Offset struct {
	// X is the horizontal grid origin in pixels.
	X int
	// Y is the vertical grid origin in pixels.
	Y int
	// Score is the image-distance score for this origin (lower is better).
	Score float64
}

Offset represents one candidate grid origin for the pixelation block alignment. The search probes multiple (X, Y) origins and ranks them by Score before committing to the full character-level search.

type Pixelator

type Pixelator interface {
	Pixelate(img *image.RGBA, originX, originY int) *image.RGBA
}

Pixelator replaces every blockSize×blockSize region of an image with the mean RGBA colour of that region. The originX and originY parameters align the grid so that it matches the original pixelation applied to the source.

type Progress

type Progress struct {
	// Kind identifies which event this Progress value represents.
	Kind EventKind

	// BestGuess is the candidate string with the lowest score found so far
	// across all offsets.
	BestGuess string
	// BestScore is the total image-distance score of BestGuess (lower is better).
	BestScore float64

	// Guess is the candidate string that triggered this specific event.
	Guess string
	// Score is the image-distance score of Guess.
	Score float64

	// Depth is the current search depth, equal to the character length of Guess.
	Depth int
	// Offset is the grid origin currently being searched.
	Offset Offset
	// Evaluated is the cumulative number of candidates evaluated since Run started.
	Evaluated int
	// OffsetsDone is the number of grid offsets fully searched so far.
	OffsetsDone int
	// OffsetsTotal is the total number of surviving grid offsets to be searched.
	OffsetsTotal int

	// PreviewImage is an optional deep copy of the pixelated guess image.
	// It is non-nil only when the Strategy implementation populates it.
	PreviewImage *image.RGBA

	// Elapsed is the wall-clock duration since Engine.Run was called.
	Elapsed time.Duration
	// Done is true only on the EventDone event.
	Done bool
	// Err carries any terminal error that caused the search to abort.
	Err error
}

Progress carries a snapshot of search state, streamed on the channel returned by Engine.Run. Consumers should switch on Kind to decide which fields are meaningful for a given event.

type Renderer

type Renderer interface {
	Render(text string, style Style) (img *image.RGBA, sentinelX int, err error)
}

Renderer renders candidate text to an RGBA image, placing a blue sentinel block immediately to the right of the text. It returns the image and the x-coordinate of that sentinel, which marks the text's right edge in pixels.

type Result

type Result struct {
	// BestGuess is the candidate string with the lowest overall score found
	// during the search rooted at this offset.
	BestGuess string
	// BestScore is the total image-distance score of BestGuess (lower is better).
	BestScore float64
	// Candidates holds every string that passed the threshold gate during the
	// search, in discovery order.
	Candidates []Eval
	// Offset is the grid origin that was searched to produce this result.
	Offset Offset
	// Err is non-nil if the search for this offset aborted due to an error.
	Err error
}

Result is the final output produced by Engine.Run for one surviving grid offset. One Result is sent per offset on the results channel returned by Run.

type Strategy

type Strategy interface {
	Search(ctx context.Context, redacted *image.RGBA, cfg Config, out chan<- Progress, results chan<- Result)
}

Strategy runs the full character-by-character search over the candidate space and streams progress and results on the provided channels. Implementations may use different algorithms (guided DFS, beam search, etc.). Search must close neither channel; Engine.Run owns their lifecycle.

type Style

type Style struct {
	// FontSize is the font size in points. Defaults to 32.
	FontSize float64
	// Bold selects the bold font variant when true.
	Bold bool
	// PaddingTop is the top padding applied before the text, in pixels. Defaults to 8.
	PaddingTop int
	// PaddingLeft is the left padding applied before the text, in pixels. Defaults to 8.
	PaddingLeft int
}

Style describes the text rendering parameters that must match the typography of the original, pre-pixelated screenshot.

Directories

Path Synopsis
cmd
unpixel command
Command unpixel will reconstruct text hidden behind pixelation, a Go port of Bishop Fox's Unredacter.
Command unpixel will reconstruct text hidden behind pixelation, a Go port of Bishop Fox's Unredacter.
Package defaults wires the standard internal components into an unpixel.Config.
Package defaults wires the standard internal components into an unpixel.Config.
internal
imutil
Package imutil provides image manipulation helpers used by the unpixel pipeline.
Package imutil provides image manipulation helpers used by the unpixel pipeline.
metric
Package metric provides image distance metrics for the unpixel pipeline.
Package metric provides image distance metrics for the unpixel pipeline.
pixelate
Package pixelate implements the block-average pixelation operation used by the unpixel pipeline.
Package pixelate implements the block-average pixelation operation used by the unpixel pipeline.
render
Package render provides the XImage text renderer for the unpixel pipeline.
Package render provides the XImage text renderer for the unpixel pipeline.
search
Package search (scorer.go) provides the pipeline Scorer that connects the renderer, pixelator, imutil helpers, and metric into the Eval call used by GuidedDFS and DiscoverOffsets.
Package search (scorer.go) provides the pipeline Scorer that connects the renderer, pixelator, imutil helpers, and metric into the Eval call used by GuidedDFS and DiscoverOffsets.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL